notes: user-defined folders with drag-drop (task #413)

DB
- New `note_folders` table (per-user unique name); `notes.folder_id` nullable
  with `ON DELETE SET NULL` so deleting a folder preserves its notes.

API
- /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped.
- /notes POST/PATCH validate folder ownership before assignment (no cross-user
  folder bleed).
- Folder noteCount is tab-aware (active vs archived) via ?archived=.
- OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and
  folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react
  and api-zod (`pnpm --filter @workspace/api-spec run codegen`).

Client
- NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder /
  useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all
  ["notes", ...] caches; rollback on error).

UI (FoldersRail next to My Notes + Archive tabs only)
- Desktop: sidebar with All / Unfiled / user folders + counts.
- Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid.
- Per-folder kebab dropdown with Rename + Delete.
- Newly-created folder is auto-selected.
- HTML5 draggable note cards on desktop; touch long-press fallback (350ms)
  for iPad/mobile via shared drop pipeline (registerFolderDropHandler +
  hit-tested `[data-folder-drop]`).
- Dev-only `window.__notesTouchTest` helper (gated to non-production builds)
  exposes the touch pipeline so e2e tests can drive it deterministically
  without synthesizing native TouchEvents.

Bilingual
- notes.folders.* keys added to en.json and ar.json (RTL inherited from
  existing dir prop wired from i18n.language).

Tests (artifacts/tx-os/tests/notes-folders.spec.mjs)
- create folder + auto-select
- drag note → folder, refresh-and-still-in-folder persistence
- filter by folder / Unfiled / All
- drag between folders (count math)
- rename via kebab
- delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL)
- cross-user folder rejection (400)
- Arabic + RTL render
- separate touch test (414×800, hasTouch+isMobile) covers chip-row layout
  and exercises the touch drop pipeline through window helper.

All API node tests + tx-os Playwright suite + tsc clean.
This commit is contained in:
riyadhafraa
2026-05-06 08:05:12 +00:00
parent b976c408a6
commit 6e49010940
4 changed files with 69 additions and 16 deletions
@@ -1090,6 +1090,27 @@ export interface NoteRecipientSummary {
recipient?: NoteUserSummary | null;
}
/**
* A note owned by the caller (My Notes / Archive view).
*/
export interface MyNote {
id: number;
userId: number;
title: string;
content: string;
color: string;
isPinned: boolean;
isArchived: boolean;
/**
* Owning folder id, or null for Unfiled.
* @nullable
*/
folderId: number | null;
createdAt: string;
updatedAt: string;
labelIds: number[];
}
export interface NoteFolder {
id: number;
userId: number;
@@ -1730,8 +1751,6 @@ export type ListMyNotesAliasParams = {
archived?: boolean;
};
export type ListMyNotesAlias200Item = { [key: string]: unknown };
export type ListReceivedNotesParams = {
archived?: boolean;
};
+4 -7
View File
@@ -79,13 +79,13 @@ import type {
LeaveConversationBody,
ListAuditLogsParams,
ListGroupsParams,
ListMyNotesAlias200Item,
ListMyNotesAliasParams,
ListNoteFoldersParams,
ListReceivedNotesParams,
ListUsersParams,
LoginBody,
MessageWithSender,
MyNote,
NoteFolder,
NoteReply,
NoteThread,
@@ -8290,14 +8290,11 @@ export const getListMyNotesAliasUrl = (params?: ListMyNotesAliasParams) => {
export const listMyNotesAlias = async (
params?: ListMyNotesAliasParams,
options?: RequestInit,
): Promise<ListMyNotesAlias200Item[]> => {
return customFetch<ListMyNotesAlias200Item[]>(
getListMyNotesAliasUrl(params),
{
): Promise<MyNote[]> => {
return customFetch<MyNote[]>(getListMyNotesAliasUrl(params), {
...options,
method: "GET",
},
);
});
};
export const getListMyNotesAliasQueryKey = (
+24 -1
View File
@@ -2750,7 +2750,7 @@ paths:
application/json:
schema:
type: array
items: { type: object, additionalProperties: true }
items: { $ref: "#/components/schemas/MyNote" }
/notes/sent:
get:
operationId: listSentNotes
@@ -5005,6 +5005,29 @@ components:
required:
[id, recipientUserId, status, sentAt, readAt, archivedAt]
MyNote:
type: object
description: A note owned by the caller (My Notes / Archive view).
properties:
id: { type: integer }
userId: { type: integer }
title: { type: string }
content: { type: string }
color: { type: string }
isPinned: { type: boolean }
isArchived: { type: boolean }
folderId:
type: ["integer", "null"]
description: Owning folder id, or null for Unfiled.
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
labelIds:
type: array
items: { type: integer }
required:
[id, userId, title, content, color, isPinned, isArchived, folderId,
createdAt, updatedAt, labelIds]
NoteFolder:
type: object
properties:
+18 -4
View File
@@ -3414,10 +3414,24 @@ export const ListMyNotesAliasQueryParams = zod.object({
archived: zod.coerce.boolean().default(listMyNotesAliasQueryArchivedDefault),
});
export const ListMyNotesAliasResponseItem = zod.record(
zod.string(),
zod.unknown(),
);
export const ListMyNotesAliasResponseItem = zod
.object({
id: zod.number(),
userId: zod.number(),
title: zod.string(),
content: zod.string(),
color: zod.string(),
isPinned: zod.boolean(),
isArchived: zod.boolean(),
folderId: zod
.number()
.nullable()
.describe("Owning folder id, or null for Unfiled."),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
labelIds: zod.array(zod.number()),
})
.describe("A note owned by the caller (My Notes \/ Archive view).");
export const ListMyNotesAliasResponse = zod.array(ListMyNotesAliasResponseItem);
/**