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:
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user