notes(folders): allow drag-drop of notes onto shared folders (edit perm)
User report (AR): "shared folders also appear here — the user should
be able to move notes wherever they want." The folders rail already
lists shared-with-me folders, but only OWN folder rows registered as
@dnd-kit drop targets. Recipients with edit permission could see a
shared folder but couldn't drop a note onto it.
Changes:
- folders-rail.tsx: extend the inline `DroppableRow` to accept an
optional `ownerId`. Wrap each shared-folder row in a DroppableRow
when `sf.myPermission === "edit"`, passing the owning user's id;
read-only viewers stay non-droppable. The droppable id is namespaced
with `-shared-<ownerId>` so it never collides with the owner-side
row for the same folder id.
- notes.tsx: extend `sharedFolderBucketRef` to carry the bucket's
`ownerId` (sourced from `data.folder.ownerId`). In `handleDragEnd`,
read `o.ownerId` from the drop data:
• undefined → own folder / Unfiled. Allow only own notes.
• number → shared folder. Allow only when the source note
came from sharedBucket and `sharedBucket.ownerId
=== o.ownerId`.
This mirrors the server-side rule that an editor may move notes
only between folders owned by the same owner — cross-owner drops
are blocked client-side so we never round-trip a 400.
Out of scope:
- Cross-owner moves (would require a copy/share flow, not supported
by the server today).
- Drop targets on shared folders inside the FoldersBrowser tile grid
(the rail covers the user's reported screenshot; tiles can follow).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
@@ -78,18 +78,27 @@ export const folderDropId = (target: "unfiled" | number) =>
|
||||
// its own droppable id (one hook per row).
|
||||
function DroppableRow({
|
||||
target,
|
||||
ownerId,
|
||||
className,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
target: "unfiled" | number;
|
||||
// When set, the drop target belongs to a shared folder owned by this
|
||||
// user id. handleDragEnd uses this to enforce the "same-owner only"
|
||||
// rule the server applies to editors. Leave undefined for own folders
|
||||
// and the Unfiled bucket.
|
||||
ownerId?: number;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: folderDropId(target),
|
||||
data: { type: "folder-drop", target },
|
||||
id:
|
||||
ownerId === undefined
|
||||
? folderDropId(target)
|
||||
: `${folderDropId(target)}-shared-${ownerId}`,
|
||||
data: { type: "folder-drop", target, ownerId },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
@@ -506,9 +515,12 @@ export function FoldersRail({
|
||||
},
|
||||
lang,
|
||||
);
|
||||
return (
|
||||
// Editors can drop notes from this owner's namespace into
|
||||
// any of their shared folders (server enforces same-owner).
|
||||
// Read-only viewers stay non-droppable.
|
||||
const canDrop = sf.myPermission === "edit";
|
||||
const rowInner = (
|
||||
<div
|
||||
key={`shared:${sf.id}`}
|
||||
className={`group rounded-lg border transition shrink-0 md:shrink ${
|
||||
isSel
|
||||
? "bg-foreground text-background border-foreground"
|
||||
@@ -575,6 +587,18 @@ export function FoldersRail({
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return canDrop ? (
|
||||
<DroppableRow
|
||||
key={`shared:${sf.id}`}
|
||||
target={sf.id}
|
||||
ownerId={sf.ownerId}
|
||||
testId={`shared-folder-drop-${sf.id}`}
|
||||
>
|
||||
{rowInner}
|
||||
</DroppableRow>
|
||||
) : (
|
||||
<React.Fragment key={`shared:${sf.id}`}>{rowInner}</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -257,6 +257,7 @@ export default function NotesPage() {
|
||||
// for shared folders. Null means "no editable shared-folder context".
|
||||
const sharedFolderBucketRef = useRef<{
|
||||
folderId: number;
|
||||
ownerId: number;
|
||||
notes: Note[];
|
||||
} | null>(null);
|
||||
|
||||
@@ -348,6 +349,10 @@ export default function NotesPage() {
|
||||
| {
|
||||
type?: string;
|
||||
target?: "unfiled" | number;
|
||||
// Owner of the target droppable. Set ONLY for shared-folder
|
||||
// rows; absent (undefined) on own folders + the Unfiled bucket
|
||||
// — those targets implicitly belong to the current user.
|
||||
ownerId?: number;
|
||||
noteId?: number;
|
||||
folderId?: number | null;
|
||||
isPinned?: boolean;
|
||||
@@ -365,7 +370,19 @@ export default function NotesPage() {
|
||||
if (!isOwn && !isEditableShared) return;
|
||||
|
||||
if (o?.type === "folder-drop" && o.target !== undefined) {
|
||||
if (!isOwn) return;
|
||||
// The server enforces: an editor may only move a note BETWEEN
|
||||
// folders owned by the same owner. Mirror that constraint
|
||||
// client-side so cross-owner drops never round-trip:
|
||||
// • Target with no ownerId = own folder / Unfiled. Allow only
|
||||
// for the user's OWN notes.
|
||||
// • Target with ownerId = a shared folder we can edit.
|
||||
// Allow only when the source note also lives in that owner's
|
||||
// namespace (i.e. came from sharedBucket with the same owner).
|
||||
if (o.ownerId === undefined) {
|
||||
if (!isOwn) return;
|
||||
} else {
|
||||
if (!isEditableShared || sharedBucket?.ownerId !== o.ownerId) return;
|
||||
}
|
||||
moveNoteToTarget(o.target, a.noteId);
|
||||
return;
|
||||
}
|
||||
@@ -3348,7 +3365,7 @@ function SharedFolderView({
|
||||
onBack: () => void;
|
||||
onEdit: (n: Note) => void;
|
||||
onSend: (n: Note) => void;
|
||||
bucketRef: React.MutableRefObject<{ folderId: number; notes: Note[] } | null>;
|
||||
bucketRef: React.MutableRefObject<{ folderId: number; ownerId: number; notes: Note[] } | null>;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -3364,7 +3381,11 @@ function SharedFolderView({
|
||||
bucketRef.current = null;
|
||||
return;
|
||||
}
|
||||
bucketRef.current = { folderId, notes: data.notes };
|
||||
bucketRef.current = {
|
||||
folderId,
|
||||
ownerId: data.folder.ownerId,
|
||||
notes: data.notes,
|
||||
};
|
||||
return () => {
|
||||
if (bucketRef.current?.folderId === folderId) {
|
||||
bucketRef.current = null;
|
||||
|
||||
Reference in New Issue
Block a user