From 188940756507dd524ffdf0b48feccaad8826e10d Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 6 May 2026 07:55:32 +0000 Subject: [PATCH] notes: user-defined folders with drag-drop (task #413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/generated/api.schemas.ts | 7 +++ lib/api-client-react/src/generated/api.ts | 57 +++++++++++++++---- lib/api-spec/openapi.yaml | 17 +++++- lib/api-zod/src/generated/api.ts | 11 ++++ 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 0091d6b7..10953ed8 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -1765,6 +1765,13 @@ export type CreateNoteFolderBody = { name: string; }; +export type UpdateNoteFolderParams = { + /** + * Tab scope for the returned noteCount (false = My Notes, true = Archive) + */ + archived?: boolean; +}; + export type UpdateNoteFolderBody = { /** * @minLength 1 diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 88f96cde..810a5eee 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -125,6 +125,7 @@ import type { UpdateLanguageBody, UpdateMyAppOrderBody, UpdateNoteFolderBody, + UpdateNoteFolderParams, UpdateNotificationPreferencesBody, UpdateRoleBody, UpdateServiceBody, @@ -9154,16 +9155,32 @@ export const useCreateNoteFolder = < /** * @summary Rename a note folder */ -export const getUpdateNoteFolderUrl = (id: number) => { - return `/api/note-folders/${id}`; +export const getUpdateNoteFolderUrl = ( + 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 ( id: number, updateNoteFolderBody: UpdateNoteFolderBody, + params?: UpdateNoteFolderParams, options?: RequestInit, ): Promise => { - return customFetch(getUpdateNoteFolderUrl(id), { + return customFetch(getUpdateNoteFolderUrl(id, params), { ...options, method: "PATCH", headers: { "Content-Type": "application/json", ...options?.headers }, @@ -9178,14 +9195,22 @@ export const getUpdateNoteFolderMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: number; data: BodyType }, + { + id: number; + data: BodyType; + params?: UpdateNoteFolderParams; + }, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { id: number; data: BodyType }, + { + id: number; + data: BodyType; + params?: UpdateNoteFolderParams; + }, TContext > => { const mutationKey = ["updateNoteFolder"]; @@ -9199,11 +9224,15 @@ export const getUpdateNoteFolderMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: number; data: BodyType } + { + id: number; + data: BodyType; + params?: UpdateNoteFolderParams; + } > = (props) => { - const { id, data } = props ?? {}; + const { id, data, params } = props ?? {}; - return updateNoteFolder(id, data, requestOptions); + return updateNoteFolder(id, data, params, requestOptions); }; return { mutationFn, ...mutationOptions }; @@ -9225,14 +9254,22 @@ export const useUpdateNoteFolder = < mutation?: UseMutationOptions< Awaited>, TError, - { id: number; data: BodyType }, + { + id: number; + data: BodyType; + params?: UpdateNoteFolderParams; + }, TContext >; request?: SecondParameter; }): UseMutationResult< Awaited>, TError, - { id: number; data: BodyType }, + { + id: number; + data: BodyType; + params?: UpdateNoteFolderParams; + }, TContext > => { return useMutation(getUpdateNoteFolderMutationOptions(options)); diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index eee5cb65..d3e03ea8 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -2945,7 +2945,12 @@ paths: application/json: schema: { $ref: "#/components/schemas/NoteFolder" } "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: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } @@ -2959,6 +2964,11 @@ paths: name: id required: true 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: required: true content: @@ -2979,6 +2989,11 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } + "409": + description: Duplicate folder name for this user + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } delete: operationId: deleteNoteFolder tags: [notes] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 8a028399..c36b216a 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -3692,6 +3692,17 @@ export const UpdateNoteFolderParams = zod.object({ 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 UpdateNoteFolderBody = zod.object({