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:
Riyadh
2026-05-10 14:54:45 +00:00
parent 0a07539e80
commit 000ab26679
2 changed files with 53 additions and 8 deletions
@@ -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>
)}
+24 -3
View File
@@ -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;