Files
TX/artifacts/tx-os/src/components/notes/folders-rail.tsx
T
riyadhafraa 5868507bcc Notes: show "Shared with me" folders on the default screen too
Task #453. Recipient (e.g. yas) couldn't see folders that an owner
(e.g. admin) had shared with them — the server returned them
correctly via /note-folders/shared-with-me, but the rail's
"Shared with me" section was gated on `mode === "rail"`. On the
default Notes screen (My Notes tab + "All notes" selection) the
rail is mounted in `chips-only` mode, so the section was never
rendered. Most recipients only ever saw the default screen, so
shared folders were effectively invisible.

Fix: drop the `mode === "rail"` gate in folders-rail.tsx so the
shared-with-me section renders in both modes. The section already
uses `contents md:block` (so chips inline into the mobile horizontal
strip), and its "Shared with me" label is already `hidden md:block`,
so the rendering stays compact on mobile and labelled on desktop in
both modes.

No backend changes; the existing socket invalidations on
`note-folder-shared` / `note-folder-unshared` already keep the
list fresh in realtime.

Out of scope:
- FoldersBrowser (the grid/list of own folders in the main content
  area) intentionally not extended to also list shared folders;
  the rail entry is sufficient and matches the task's "as a chip
  alongside the own-folder chips" outcome on mobile and
  "Shared with me" sidebar section on desktop.
- No changes to permissions, notifications, or per-note sharing.
- Notes top-bar freeze (Task #452) is untouched.
2026-05-10 09:53:06 +00:00

679 lines
24 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Folder,
FolderOpen,
FolderPlus,
Inbox as InboxIcon,
Layers,
MoreVertical,
Pencil,
Share2,
Trash2,
Users,
Check,
X,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
type NoteFolder,
type SharedFolder,
useCreateFolder,
useDeleteFolder,
useSharedWithMeFolders,
useUpdateFolder,
userDisplayName,
} from "@/lib/notes-api";
import { useToast } from "@/hooks/use-toast";
export type FolderSelection =
| { kind: "all" }
| { kind: "unfiled" }
| { kind: "folder"; id: number }
// A folder shared WITH the current user. Distinct kind because the
// notes inside come from a different endpoint, the rail row carries
// the owner's display name, and every write affordance is hidden.
| { kind: "shared-folder"; id: number };
export function selectionMatches(a: FolderSelection, b: FolderSelection): boolean {
if (a.kind !== b.kind) return false;
if (
(a.kind === "folder" && b.kind === "folder") ||
(a.kind === "shared-folder" && b.kind === "shared-folder")
) {
return a.id === b.id;
}
return true;
}
// Module-scoped drag id. Used by both HTML5 drag (desktop) and the touch
// long-press fallback (mobile / iPad) so a single drop pipeline handles both.
let DRAGGING_NOTE_ID: number | null = null;
export function setDraggingNoteId(id: number | null) {
DRAGGING_NOTE_ID = id;
}
export function getDraggingNoteId(): number | null {
return DRAGGING_NOTE_ID;
}
// Touch fallback: NotesPage registers its drop handler so the NoteCard touch
// handlers can resolve a drop target by hit-testing the rendered rail.
let DROP_HANDLER: ((target: "unfiled" | number) => void) | null = null;
export function registerFolderDropHandler(
fn: ((target: "unfiled" | number) => void) | null,
) {
DROP_HANDLER = fn;
}
export function dispatchTouchDropAt(clientX: number, clientY: number): boolean {
if (typeof document === "undefined") return false;
const el = document.elementFromPoint(clientX, clientY);
if (!el) return false;
const drop = (el as HTMLElement).closest<HTMLElement>("[data-folder-drop]");
if (!drop || !DROP_HANDLER) return false;
const target = drop.getAttribute("data-folder-drop")!;
if (target === "unfiled") DROP_HANDLER("unfiled");
else {
const id = Number(target);
if (Number.isInteger(id)) DROP_HANDLER(id);
else return false;
}
return true;
}
// Highlight (or clear) a folder row under the finger during a touch drag.
export function highlightTouchDropAt(clientX: number, clientY: number) {
if (typeof document === "undefined") return;
for (const node of Array.from(
document.querySelectorAll<HTMLElement>("[data-folder-drop]"),
)) {
node.setAttribute("data-drop-active", "false");
}
const el = document.elementFromPoint(clientX, clientY);
const drop = (el as HTMLElement | null)?.closest<HTMLElement>(
"[data-folder-drop]",
);
if (drop) drop.setAttribute("data-drop-active", "true");
}
export function clearTouchDropHighlights() {
if (typeof document === "undefined") return;
for (const node of Array.from(
document.querySelectorAll<HTMLElement>("[data-folder-drop]"),
)) {
node.setAttribute("data-drop-active", "false");
}
}
// Expose the touch drop pipeline to window in development so e2e tests can
// drive it deterministically without trying to synthesize browser-specific
// native touch events. Gated to non-production builds.
if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
(window as unknown as Record<string, unknown>).__notesTouchTest = {
setDraggingNoteId,
dispatchTouchDropAt,
highlightTouchDropAt,
clearTouchDropHighlights,
};
}
interface Props {
folders: NoteFolder[];
selected: FolderSelection;
onSelect: (s: FolderSelection) => void;
unfiledCount: number;
totalCount: number;
onDropNote: (target: "unfiled" | number) => void;
// Owner action: open the folder-share dialog. The page handles mounting
// <FolderShareDialog> so the rail stays focused on navigation/selection.
onShareFolder?: (folder: NoteFolder) => void;
// "rail" → full sidebar / chip rail with folder rows (default).
// "chips-only" → render only the All-notes / Unfiled / "+ New folder"
// chips. Folder rows are owned by the FoldersBrowser in the
// main content so test ids don't collide.
mode?: "rail" | "chips-only";
}
export function FoldersRail({
folders,
selected,
onSelect,
unfiledCount,
totalCount,
onDropNote,
onShareFolder,
mode = "rail",
}: Props) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Folders shared WITH the current user. Rendered in a dedicated section
// below the owner's folders so the two namespaces are visually distinct.
const { data: sharedFolders = [] } = useSharedWithMeFolders();
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState("");
const [hoverTarget, setHoverTarget] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<NoteFolder | null>(null);
const create = useCreateFolder();
const update = useUpdateFolder();
const del = useDeleteFolder();
const { toast } = useToast();
// Map server error → user-facing toast. 409 = duplicate folder name for
// this user; anything else is a generic failure.
const reportFolderError = (e: unknown, fallbackKey: string, fallback: string) => {
const status =
typeof e === "object" && e !== null && "status" in e
? Number((e as { status?: unknown }).status)
: 0;
if (status === 409) {
toast({
title: t("notes.folders.duplicateTitle", "Folder already exists"),
description: t(
"notes.folders.duplicateDesc",
"Pick a different name — you already have a folder with that name.",
),
variant: "destructive",
});
} else {
toast({ title: t(fallbackKey, fallback), variant: "destructive" });
}
};
const submitCreate = () => {
const name = newName.trim();
if (!name) return;
create.mutate(name, {
onSuccess: (folder) => {
setNewName("");
setCreating(false);
// Required by spec: newly-created folder is immediately selected so
// the user lands on its (empty) view.
onSelect({ kind: "folder", id: folder.id });
},
onError: (e) =>
reportFolderError(e, "notes.folders.createFailed", "Could not create folder"),
});
};
const renderRow = (
key: string,
{
icon,
label,
count,
isSelected,
onClick,
onDrop,
dropTargetTestId,
dropTarget,
action,
content,
}: {
icon: React.ReactNode;
label: React.ReactNode;
count: number;
isSelected: boolean;
onClick: () => void;
onDrop?: () => void;
dropTargetTestId: string;
dropTarget?: "unfiled" | number;
action?: React.ReactNode;
content?: React.ReactNode;
},
) => {
const isHover = hoverTarget === key;
return (
<div
key={key}
data-testid={dropTargetTestId}
data-folder-drop={dropTarget !== undefined ? String(dropTarget) : undefined}
data-drop-active={isHover ? "true" : "false"}
onDragOver={(e) => {
if (!onDrop) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (hoverTarget !== key) setHoverTarget(key);
}}
onDragLeave={() => {
if (hoverTarget === key) setHoverTarget(null);
}}
onDrop={(e) => {
if (!onDrop) return;
e.preventDefault();
setHoverTarget(null);
onDrop();
}}
className={`group rounded-lg border transition shrink-0 md:shrink ${
isSelected
? "bg-foreground text-background border-foreground"
: "border-transparent hover:bg-slate-100"
} ${isHover ? "ring-2 ring-amber-400 ring-offset-1" : ""} data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[drop-active=true]:ring-offset-1`}
>
{content ?? (
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
>
<span className="shrink-0">{icon}</span>
<span className="flex-1 truncate">{label}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
isSelected
? "bg-background/20 text-background"
: "bg-slate-200/80 text-muted-foreground"
}`}
data-testid={`${dropTargetTestId}-count`}
>
{count}
</span>
{action}
</button>
)}
</div>
);
};
return (
<aside
// Mobile: horizontal chip row above the notes grid.
// Desktop (md+): fixed sidebar to the start of the grid.
className="w-full md:w-56 shrink-0 flex md:block gap-1 md:gap-0 md:space-y-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0"
data-testid="notes-folders-rail"
>
<div className="hidden md:block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground px-2 mb-1">
{t("notes.folders.title", "Folders")}
</div>
{renderRow("all", {
icon: <Layers size={14} />,
label: t("notes.folders.all", "All notes"),
count: totalCount,
isSelected: selected.kind === "all",
onClick: () => onSelect({ kind: "all" }),
dropTargetTestId: "folder-drop-all",
})}
{renderRow("unfiled", {
icon: <InboxIcon size={14} />,
label: t("notes.folders.unfiled", "Unfiled"),
count: unfiledCount,
isSelected: selected.kind === "unfiled",
onClick: () => onSelect({ kind: "unfiled" }),
onDrop: () => onDropNote("unfiled"),
dropTargetTestId: "folder-drop-unfiled",
dropTarget: "unfiled",
})}
<div className="contents md:block md:pt-1">
{mode === "rail" && folders.map((f) => {
const isEditing = editingId === f.id;
const isSel = selected.kind === "folder" && selected.id === f.id;
return renderRow(`f:${f.id}`, {
icon: isSel ? <FolderOpen size={14} /> : <Folder size={14} />,
label: f.name,
count: f.noteCount,
isSelected: isSel,
onClick: () => onSelect({ kind: "folder", id: f.id }),
onDrop: () => onDropNote(f.id),
dropTargetTestId: `folder-drop-${f.id}`,
dropTarget: f.id,
content: isEditing ? (
<div className="flex items-center gap-1 px-2 py-1.5">
<Folder size={14} className="text-muted-foreground shrink-0" />
<Input
autoFocus
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && editingName.trim()) {
update.mutate(
{ id: f.id, name: editingName.trim() },
{
onSuccess: () => setEditingId(null),
onError: (e) =>
reportFolderError(
e,
"notes.folders.renameFailed",
"Could not rename folder",
),
},
);
}
if (e.key === "Escape") setEditingId(null);
}}
className="h-7 text-sm"
data-testid={`folder-rename-input-${f.id}`}
/>
<button
className="p-1 text-muted-foreground hover:text-foreground"
onClick={() => {
if (editingName.trim()) {
update.mutate(
{ id: f.id, name: editingName.trim() },
{
onSuccess: () => setEditingId(null),
onError: (e) =>
reportFolderError(
e,
"notes.folders.renameFailed",
"Could not rename folder",
),
},
);
}
}}
aria-label={t("common.save", "Save")}
data-testid={`folder-rename-save-${f.id}`}
>
<Check size={14} />
</button>
<button
className="p-1 text-muted-foreground hover:text-foreground"
onClick={() => setEditingId(null)}
aria-label={t("common.cancel", "Cancel")}
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-stretch">
<button
type="button"
onClick={() => onSelect({ kind: "folder", id: f.id })}
className="flex-1 flex items-center gap-2 px-2.5 py-1.5 text-sm text-start min-w-0 whitespace-nowrap"
data-testid={`folder-select-${f.id}`}
>
<span className="shrink-0">
{isSel ? <FolderOpen size={14} /> : <Folder size={14} />}
</span>
<span className="flex-1 truncate">{f.name}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full ${
isSel
? "bg-background/20 text-background"
: "bg-slate-200/80 text-muted-foreground"
}`}
data-testid={`folder-drop-${f.id}-count`}
>
{f.noteCount}
</span>
{f.sharedWithCount > 0 && (
<span
className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${
isSel
? "bg-background/20 text-background"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
}`}
title={t("notes.folders.sharedWithCount", {
defaultValue_one: "Shared with 1 person",
defaultValue_other: "Shared with {{count}} people",
count: f.sharedWithCount,
})}
data-testid={`folder-shared-badge-${f.id}`}
>
<Users size={10} />
{f.sharedWithCount}
</span>
)}
</button>
<div className="flex items-center pe-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={`p-1 rounded hover:bg-black/10 ${
isSel ? "text-background/80" : "text-muted-foreground"
}`}
aria-label={t("notes.folders.actions", "Folder actions")}
data-testid={`folder-menu-${f.id}`}
>
<MoreVertical size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onSelect={() => {
setEditingId(f.id);
setEditingName(f.name);
}}
data-testid={`folder-rename-${f.id}`}
>
<Pencil size={14} className="me-2" />
{t("notes.folders.rename", "Rename folder")}
</DropdownMenuItem>
{onShareFolder && (
<DropdownMenuItem
onSelect={() => onShareFolder(f)}
data-testid={`folder-share-${f.id}`}
>
<Share2 size={14} className="me-2" />
{t("notes.folders.share", "Share folder")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => setPendingDelete(f)}
className="text-red-600 focus:text-red-600"
data-testid={`folder-delete-${f.id}`}
>
<Trash2 size={14} className="me-2" />
{t("notes.folders.delete", "Delete folder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
),
});
})}
</div>
{creating ? (
<div className="flex items-center gap-1 px-2 py-1.5 shrink-0">
<FolderPlus size={14} className="text-muted-foreground" />
<Input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("notes.folders.namePlaceholder", "Folder name")}
onKeyDown={(e) => {
if (e.key === "Enter") submitCreate();
if (e.key === "Escape") {
setNewName("");
setCreating(false);
}
}}
className="h-7 text-sm"
data-testid="folder-create-input"
/>
<Button
size="sm"
variant="ghost"
className="h-7 px-1"
disabled={!newName.trim() || create.isPending}
onClick={submitCreate}
data-testid="folder-create-submit"
>
<Check size={14} />
</Button>
</div>
) : (
<button
type="button"
onClick={() => setCreating(true)}
className="shrink-0 flex items-center gap-2 px-2.5 py-1.5 text-sm rounded-lg text-muted-foreground hover:bg-slate-100 hover:text-foreground whitespace-nowrap md:w-full"
data-testid="folder-create-open"
>
<FolderPlus size={14} />
<span>{t("notes.folders.new", "New folder")}</span>
</button>
)}
{sharedFolders.length > 0 && (
<div
className="contents md:block md:pt-3"
data-testid="shared-with-me-section"
>
<div className="hidden md:block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground px-2 mb-1 mt-3">
{t("notes.folders.sharedWithMe", "Shared with me")}
</div>
{sharedFolders.map((sf: SharedFolder) => {
const isSel =
selected.kind === "shared-folder" && selected.id === sf.id;
const ownerName = userDisplayName(
{
id: sf.ownerId,
username: sf.ownerUsername,
displayNameAr: sf.ownerDisplayNameAr,
displayNameEn: sf.ownerDisplayNameEn,
},
lang,
);
return (
<div
key={`shared:${sf.id}`}
className={`group rounded-lg border transition shrink-0 md:shrink ${
isSel
? "bg-foreground text-background border-foreground"
: "border-transparent hover:bg-slate-100"
}`}
data-testid={`shared-folder-row-${sf.id}`}
>
<button
type="button"
onClick={() => onSelect({ kind: "shared-folder", id: sf.id })}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
data-testid={`shared-folder-select-${sf.id}`}
>
<span className="shrink-0">
<Share2 size={14} />
</span>
<span className="flex-1 min-w-0">
<span className="block truncate">{sf.name}</span>
<span
className={`block truncate text-[10px] ${
isSel ? "text-background/80" : "text-muted-foreground"
}`}
>
{t("notes.folders.sharedByName", "by {{name}}", {
name: ownerName,
})}
</span>
</span>
<span
className={`shrink-0 text-[10px] tabular-nums ${
isSel ? "text-background/80" : "text-muted-foreground"
}`}
data-testid={`shared-folder-count-${sf.id}`}
>
{sf.noteCount}
</span>
</button>
</div>
);
})}
</div>
)}
<div className="hidden md:block text-[10px] text-muted-foreground/80 px-2 pt-1 leading-snug">
{t(
"notes.folders.dragHint",
"Drag a note onto a folder to move it.",
)}
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
>
<AlertDialogContent
data-testid={
pendingDelete
? `folder-delete-dialog-${pendingDelete.id}`
: "folder-delete-dialog"
}
>
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.folders.deleteTitle", "Delete this folder?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.folders.deleteConfirm",
"Delete this folder? Notes inside will move to Unfiled.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
data-testid={
pendingDelete
? `folder-delete-cancel-${pendingDelete.id}`
: "folder-delete-cancel"
}
>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
data-testid={
pendingDelete
? `folder-delete-confirm-${pendingDelete.id}`
: "folder-delete-confirm"
}
onClick={() => {
const target = pendingDelete;
if (!target) return;
del.mutate(target.id, {
onSuccess: () => {
if (
selected.kind === "folder" &&
selected.id === target.id
) {
onSelect({ kind: "all" });
}
},
onError: (e) =>
reportFolderError(
e,
"notes.folders.deleteFailed",
"Could not delete folder",
),
});
setPendingDelete(null);
}}
>
{t("notes.folders.deleteAction", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</aside>
);
}