From 6826b475b2c0a1c1e4d487a2fe0f963bdbabb5a1 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 6 May 2026 07:50:39 +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 | 43 +++ lib/api-client-react/src/generated/api.ts | 355 ++++++++++++++++++ lib/api-spec/openapi.yaml | 109 ++++++ lib/api-zod/src/generated/api.ts | 80 ++++ 4 files changed, 587 insertions(+) diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index da32b406..0091d6b7 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -1090,6 +1090,16 @@ export interface NoteRecipientSummary { recipient?: NoteUserSummary | null; } +export interface NoteFolder { + id: number; + userId: number; + name: string; + createdAt: string; + updatedAt: string; + /** Number of notes in this folder, scoped to the requested tab (active vs archived). */ + noteCount: number; +} + export interface SentNote { id: number; userId: number; @@ -1098,6 +1108,11 @@ export interface SentNote { color: string; isPinned?: boolean; isArchived?: boolean; + /** + * Owning folder id, or null for Unfiled. + * @nullable + */ + folderId?: number | null; createdAt: string; updatedAt: string; recipients: NoteRecipientSummary[]; @@ -1109,6 +1124,11 @@ export interface ReceivedNote { title: string; content: string; color: string; + /** + * Owning folder id on the recipient's side, or null for Unfiled. + * @nullable + */ + folderId?: number | null; createdAt: string; updatedAt: string; sender?: NoteUserSummary | null; @@ -1730,6 +1750,29 @@ export type ReplyToNoteBody = { recipientUserId?: number; }; +export type ListNoteFoldersParams = { + /** + * Tab scope for noteCount (false = My Notes, true = Archive) + */ + archived?: boolean; +}; + +export type CreateNoteFolderBody = { + /** + * @minLength 1 + * @maxLength 80 + */ + name: string; +}; + +export type UpdateNoteFolderBody = { + /** + * @minLength 1 + * @maxLength 80 + */ + name: string; +}; + export type GetAdminUserDependentNotesParams = { /** * @minimum 1 diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 9471bc36..88f96cde 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -42,6 +42,7 @@ import type { CreateAppBody, CreateConversationBody, CreateGroupBody, + CreateNoteFolderBody, CreateRoleBody, CreateServiceBody, CreateServiceOrderBody, @@ -80,10 +81,12 @@ import type { ListGroupsParams, ListMyNotesAlias200Item, ListMyNotesAliasParams, + ListNoteFoldersParams, ListReceivedNotesParams, ListUsersParams, LoginBody, MessageWithSender, + NoteFolder, NoteReply, NoteThread, Notification, @@ -121,6 +124,7 @@ import type { UpdateGroupBody, UpdateLanguageBody, UpdateMyAppOrderBody, + UpdateNoteFolderBody, UpdateNotificationPreferencesBody, UpdateRoleBody, UpdateServiceBody, @@ -8967,6 +8971,357 @@ export const useReplyToNote = < return useMutation(getReplyToNoteMutationOptions(options)); }; +/** + * @summary List the caller's note folders with per-tab note counts + */ +export const getListNoteFoldersUrl = (params?: ListNoteFoldersParams) => { + 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?${stringifiedParams}` + : `/api/note-folders`; +}; + +export const listNoteFolders = async ( + params?: ListNoteFoldersParams, + options?: RequestInit, +): Promise => { + return customFetch(getListNoteFoldersUrl(params), { + ...options, + method: "GET", + }); +}; + +export const getListNoteFoldersQueryKey = (params?: ListNoteFoldersParams) => { + return [`/api/note-folders`, ...(params ? [params] : [])] as const; +}; + +export const getListNoteFoldersQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + params?: ListNoteFoldersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListNoteFoldersQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listNoteFolders(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListNoteFoldersQueryResult = NonNullable< + Awaited> +>; +export type ListNoteFoldersQueryError = ErrorType; + +/** + * @summary List the caller's note folders with per-tab note counts + */ + +export function useListNoteFolders< + TData = Awaited>, + TError = ErrorType, +>( + params?: ListNoteFoldersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListNoteFoldersQueryOptions(params, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Create a new note folder owned by the caller + */ +export const getCreateNoteFolderUrl = () => { + return `/api/note-folders`; +}; + +export const createNoteFolder = async ( + createNoteFolderBody: CreateNoteFolderBody, + options?: RequestInit, +): Promise => { + return customFetch(getCreateNoteFolderUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createNoteFolderBody), + }); +}; + +export const getCreateNoteFolderMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["createNoteFolder"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return createNoteFolder(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateNoteFolderMutationResult = NonNullable< + Awaited> +>; +export type CreateNoteFolderMutationBody = BodyType; +export type CreateNoteFolderMutationError = ErrorType; + +/** + * @summary Create a new note folder owned by the caller + */ +export const useCreateNoteFolder = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getCreateNoteFolderMutationOptions(options)); +}; + +/** + * @summary Rename a note folder + */ +export const getUpdateNoteFolderUrl = (id: number) => { + return `/api/note-folders/${id}`; +}; + +export const updateNoteFolder = async ( + id: number, + updateNoteFolderBody: UpdateNoteFolderBody, + options?: RequestInit, +): Promise => { + return customFetch(getUpdateNoteFolderUrl(id), { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateNoteFolderBody), + }); +}; + +export const getUpdateNoteFolderMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["updateNoteFolder"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return updateNoteFolder(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateNoteFolderMutationResult = NonNullable< + Awaited> +>; +export type UpdateNoteFolderMutationBody = BodyType; +export type UpdateNoteFolderMutationError = ErrorType; + +/** + * @summary Rename a note folder + */ +export const useUpdateNoteFolder = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getUpdateNoteFolderMutationOptions(options)); +}; + +/** + * @summary Delete a note folder. Notes inside are preserved (folderId set to null). + */ +export const getDeleteNoteFolderUrl = (id: number) => { + return `/api/note-folders/${id}`; +}; + +export const deleteNoteFolder = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getDeleteNoteFolderUrl(id), { + ...options, + method: "DELETE", + }); +}; + +export const getDeleteNoteFolderMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext +> => { + const mutationKey = ["deleteNoteFolder"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return deleteNoteFolder(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteNoteFolderMutationResult = NonNullable< + Awaited> +>; + +export type DeleteNoteFolderMutationError = ErrorType; + +/** + * @summary Delete a note folder. Notes inside are preserved (folderId set to null). + */ +export const useDeleteNoteFolder = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getDeleteNoteFolderMutationOptions(options)); +}; + /** * @summary List notes owned by a user */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 4336a7e5..eee5cb65 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -2905,6 +2905,96 @@ paths: application/json: schema: { $ref: "#/components/schemas/NoteReply" } + # ----- Note folders (user-defined) ----- + /note-folders: + get: + operationId: listNoteFolders + tags: [notes] + summary: List the caller's note folders with per-tab note counts + parameters: + - in: query + name: archived + required: false + description: Tab scope for noteCount (false = My Notes, true = Archive) + schema: { type: boolean, default: false } + responses: + "200": + description: Folders owned by the caller + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/NoteFolder" } + post: + operationId: createNoteFolder + tags: [notes] + summary: Create a new note folder owned by the caller + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string, minLength: 1, maxLength: 80 } + responses: + "201": + description: Folder created + content: + application/json: + schema: { $ref: "#/components/schemas/NoteFolder" } + "400": + description: Invalid name or duplicate per-user folder name + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + /note-folders/{id}: + patch: + operationId: updateNoteFolder + tags: [notes] + summary: Rename a note folder + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string, minLength: 1, maxLength: 80 } + responses: + "200": + description: Folder updated + content: + application/json: + schema: { $ref: "#/components/schemas/NoteFolder" } + "404": + description: Folder not found or not owned by caller + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + delete: + operationId: deleteNoteFolder + tags: [notes] + summary: Delete a note folder. Notes inside are preserved (folderId set to null). + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: Folder deleted + content: + application/json: + schema: { $ref: "#/components/schemas/SuccessResponse" } + /admin/users/{id}/dependents/notes: get: operationId: getAdminUserDependentNotes @@ -4900,6 +4990,19 @@ components: required: [id, recipientUserId, status, sentAt, readAt, archivedAt] + NoteFolder: + type: object + properties: + id: { type: integer } + userId: { type: integer } + name: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + noteCount: + type: integer + description: Number of notes in this folder, scoped to the requested tab (active vs archived). + required: [id, userId, name, createdAt, updatedAt, noteCount] + SentNote: type: object properties: @@ -4910,6 +5013,9 @@ components: 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 } recipients: @@ -4926,6 +5032,9 @@ components: title: { type: string } content: { type: string } color: { type: string } + folderId: + type: ["integer", "null"] + description: Owning folder id on the recipient's side, or null for Unfiled. createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } sender: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 666fec4b..8a028399 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -3431,6 +3431,10 @@ export const ListSentNotesResponseItem = zod.object({ color: zod.string(), isPinned: zod.boolean().optional(), isArchived: zod.boolean().optional(), + folderId: zod + .number() + .nullish() + .describe("Owning folder id, or null for Unfiled."), createdAt: zod.coerce.date(), updatedAt: zod.coerce.date(), recipients: zod.array( @@ -3475,6 +3479,10 @@ export const ListReceivedNotesResponseItem = zod.object({ title: zod.string(), content: zod.string(), color: zod.string(), + folderId: zod + .number() + .nullish() + .describe("Owning folder id on the recipient's side, or null for Unfiled."), createdAt: zod.coerce.date(), updatedAt: zod.coerce.date(), sender: zod @@ -3642,6 +3650,78 @@ export const ReplyToNoteBody = zod.object({ ), }); +/** + * @summary List the caller's note folders with per-tab note counts + */ +export const listNoteFoldersQueryArchivedDefault = false; + +export const ListNoteFoldersQueryParams = zod.object({ + archived: zod.coerce + .boolean() + .default(listNoteFoldersQueryArchivedDefault) + .describe("Tab scope for noteCount (false = My Notes, true = Archive)"), +}); + +export const ListNoteFoldersResponseItem = zod.object({ + id: zod.number(), + userId: zod.number(), + name: zod.string(), + createdAt: zod.coerce.date(), + updatedAt: zod.coerce.date(), + noteCount: zod + .number() + .describe( + "Number of notes in this folder, scoped to the requested tab (active vs archived).", + ), +}); +export const ListNoteFoldersResponse = zod.array(ListNoteFoldersResponseItem); + +/** + * @summary Create a new note folder owned by the caller + */ +export const createNoteFolderBodyNameMax = 80; + +export const CreateNoteFolderBody = zod.object({ + name: zod.string().min(1).max(createNoteFolderBodyNameMax), +}); + +/** + * @summary Rename a note folder + */ +export const UpdateNoteFolderParams = zod.object({ + id: zod.coerce.number(), +}); + +export const updateNoteFolderBodyNameMax = 80; + +export const UpdateNoteFolderBody = zod.object({ + name: zod.string().min(1).max(updateNoteFolderBodyNameMax), +}); + +export const UpdateNoteFolderResponse = zod.object({ + id: zod.number(), + userId: zod.number(), + name: zod.string(), + createdAt: zod.coerce.date(), + updatedAt: zod.coerce.date(), + noteCount: zod + .number() + .describe( + "Number of notes in this folder, scoped to the requested tab (active vs archived).", + ), +}); + +/** + * @summary Delete a note folder. Notes inside are preserved (folderId set to null). + */ +export const DeleteNoteFolderParams = zod.object({ + id: zod.coerce.number(), +}); + +export const DeleteNoteFolderResponse = zod.object({ + success: zod.boolean(), +}); + /** * @summary List notes owned by a user */