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 07:55:32 +00:00
parent 9fd4671327
commit 1889407565
4 changed files with 81 additions and 11 deletions
@@ -1765,6 +1765,13 @@ export type CreateNoteFolderBody = {
name: string; name: string;
}; };
export type UpdateNoteFolderParams = {
/**
* Tab scope for the returned noteCount (false = My Notes, true = Archive)
*/
archived?: boolean;
};
export type UpdateNoteFolderBody = { export type UpdateNoteFolderBody = {
/** /**
* @minLength 1 * @minLength 1
+47 -10
View File
@@ -125,6 +125,7 @@ import type {
UpdateLanguageBody, UpdateLanguageBody,
UpdateMyAppOrderBody, UpdateMyAppOrderBody,
UpdateNoteFolderBody, UpdateNoteFolderBody,
UpdateNoteFolderParams,
UpdateNotificationPreferencesBody, UpdateNotificationPreferencesBody,
UpdateRoleBody, UpdateRoleBody,
UpdateServiceBody, UpdateServiceBody,
@@ -9154,16 +9155,32 @@ export const useCreateNoteFolder = <
/** /**
* @summary Rename a note folder * @summary Rename a note folder
*/ */
export const getUpdateNoteFolderUrl = (id: number) => { export const getUpdateNoteFolderUrl = (
return `/api/note-folders/${id}`; id: number,
params?: UpdateNoteFolderParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/note-folders/${id}?${stringifiedParams}`
: `/api/note-folders/${id}`;
}; };
export const updateNoteFolder = async ( export const updateNoteFolder = async (
id: number, id: number,
updateNoteFolderBody: UpdateNoteFolderBody, updateNoteFolderBody: UpdateNoteFolderBody,
params?: UpdateNoteFolderParams,
options?: RequestInit, options?: RequestInit,
): Promise<NoteFolder> => { ): Promise<NoteFolder> => {
return customFetch<NoteFolder>(getUpdateNoteFolderUrl(id), { return customFetch<NoteFolder>(getUpdateNoteFolderUrl(id, params), {
...options, ...options,
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers }, headers: { "Content-Type": "application/json", ...options?.headers },
@@ -9178,14 +9195,22 @@ export const getUpdateNoteFolderMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNoteFolder>>, Awaited<ReturnType<typeof updateNoteFolder>>,
TError, TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> }, {
id: number;
data: BodyType<UpdateNoteFolderBody>;
params?: UpdateNoteFolderParams;
},
TContext TContext
>; >;
request?: SecondParameter<typeof customFetch>; request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof updateNoteFolder>>, Awaited<ReturnType<typeof updateNoteFolder>>,
TError, TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> }, {
id: number;
data: BodyType<UpdateNoteFolderBody>;
params?: UpdateNoteFolderParams;
},
TContext TContext
> => { > => {
const mutationKey = ["updateNoteFolder"]; const mutationKey = ["updateNoteFolder"];
@@ -9199,11 +9224,15 @@ export const getUpdateNoteFolderMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateNoteFolder>>, Awaited<ReturnType<typeof updateNoteFolder>>,
{ id: number; data: BodyType<UpdateNoteFolderBody> } {
id: number;
data: BodyType<UpdateNoteFolderBody>;
params?: UpdateNoteFolderParams;
}
> = (props) => { > = (props) => {
const { id, data } = props ?? {}; const { id, data, params } = props ?? {};
return updateNoteFolder(id, data, requestOptions); return updateNoteFolder(id, data, params, requestOptions);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -9225,14 +9254,22 @@ export const useUpdateNoteFolder = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNoteFolder>>, Awaited<ReturnType<typeof updateNoteFolder>>,
TError, TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> }, {
id: number;
data: BodyType<UpdateNoteFolderBody>;
params?: UpdateNoteFolderParams;
},
TContext TContext
>; >;
request?: SecondParameter<typeof customFetch>; request?: SecondParameter<typeof customFetch>;
}): UseMutationResult< }): UseMutationResult<
Awaited<ReturnType<typeof updateNoteFolder>>, Awaited<ReturnType<typeof updateNoteFolder>>,
TError, TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> }, {
id: number;
data: BodyType<UpdateNoteFolderBody>;
params?: UpdateNoteFolderParams;
},
TContext TContext
> => { > => {
return useMutation(getUpdateNoteFolderMutationOptions(options)); return useMutation(getUpdateNoteFolderMutationOptions(options));
+16 -1
View File
@@ -2945,7 +2945,12 @@ paths:
application/json: application/json:
schema: { $ref: "#/components/schemas/NoteFolder" } schema: { $ref: "#/components/schemas/NoteFolder" }
"400": "400":
description: Invalid name or duplicate per-user folder name description: Invalid folder name (empty or too long)
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
"409":
description: Duplicate folder name for this user
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" } schema: { $ref: "#/components/schemas/ErrorResponse" }
@@ -2959,6 +2964,11 @@ paths:
name: id name: id
required: true required: true
schema: { type: integer } schema: { type: integer }
- in: query
name: archived
required: false
description: Tab scope for the returned noteCount (false = My Notes, true = Archive)
schema: { type: boolean, default: false }
requestBody: requestBody:
required: true required: true
content: content:
@@ -2979,6 +2989,11 @@ paths:
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" } schema: { $ref: "#/components/schemas/ErrorResponse" }
"409":
description: Duplicate folder name for this user
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
delete: delete:
operationId: deleteNoteFolder operationId: deleteNoteFolder
tags: [notes] tags: [notes]
+11
View File
@@ -3692,6 +3692,17 @@ export const UpdateNoteFolderParams = zod.object({
id: zod.coerce.number(), id: zod.coerce.number(),
}); });
export const updateNoteFolderQueryArchivedDefault = false;
export const UpdateNoteFolderQueryParams = zod.object({
archived: zod.coerce
.boolean()
.default(updateNoteFolderQueryArchivedDefault)
.describe(
"Tab scope for the returned noteCount (false = My Notes, true = Archive)",
),
});
export const updateNoteFolderBodyNameMax = 80; export const updateNoteFolderBodyNameMax = 80;
export const UpdateNoteFolderBody = zod.object({ export const UpdateNoteFolderBody = zod.object({