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:
@@ -1090,6 +1090,27 @@ export interface NoteRecipientSummary {
|
|||||||
recipient?: NoteUserSummary | null;
|
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 {
|
export interface NoteFolder {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -1730,8 +1751,6 @@ export type ListMyNotesAliasParams = {
|
|||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListMyNotesAlias200Item = { [key: string]: unknown };
|
|
||||||
|
|
||||||
export type ListReceivedNotesParams = {
|
export type ListReceivedNotesParams = {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,13 +79,13 @@ import type {
|
|||||||
LeaveConversationBody,
|
LeaveConversationBody,
|
||||||
ListAuditLogsParams,
|
ListAuditLogsParams,
|
||||||
ListGroupsParams,
|
ListGroupsParams,
|
||||||
ListMyNotesAlias200Item,
|
|
||||||
ListMyNotesAliasParams,
|
ListMyNotesAliasParams,
|
||||||
ListNoteFoldersParams,
|
ListNoteFoldersParams,
|
||||||
ListReceivedNotesParams,
|
ListReceivedNotesParams,
|
||||||
ListUsersParams,
|
ListUsersParams,
|
||||||
LoginBody,
|
LoginBody,
|
||||||
MessageWithSender,
|
MessageWithSender,
|
||||||
|
MyNote,
|
||||||
NoteFolder,
|
NoteFolder,
|
||||||
NoteReply,
|
NoteReply,
|
||||||
NoteThread,
|
NoteThread,
|
||||||
@@ -8290,14 +8290,11 @@ export const getListMyNotesAliasUrl = (params?: ListMyNotesAliasParams) => {
|
|||||||
export const listMyNotesAlias = async (
|
export const listMyNotesAlias = async (
|
||||||
params?: ListMyNotesAliasParams,
|
params?: ListMyNotesAliasParams,
|
||||||
options?: RequestInit,
|
options?: RequestInit,
|
||||||
): Promise<ListMyNotesAlias200Item[]> => {
|
): Promise<MyNote[]> => {
|
||||||
return customFetch<ListMyNotesAlias200Item[]>(
|
return customFetch<MyNote[]>(getListMyNotesAliasUrl(params), {
|
||||||
getListMyNotesAliasUrl(params),
|
|
||||||
{
|
|
||||||
...options,
|
...options,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getListMyNotesAliasQueryKey = (
|
export const getListMyNotesAliasQueryKey = (
|
||||||
|
|||||||
@@ -2750,7 +2750,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items: { type: object, additionalProperties: true }
|
items: { $ref: "#/components/schemas/MyNote" }
|
||||||
/notes/sent:
|
/notes/sent:
|
||||||
get:
|
get:
|
||||||
operationId: listSentNotes
|
operationId: listSentNotes
|
||||||
@@ -5005,6 +5005,29 @@ components:
|
|||||||
required:
|
required:
|
||||||
[id, recipientUserId, status, sentAt, readAt, archivedAt]
|
[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:
|
NoteFolder:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -3414,10 +3414,24 @@ export const ListMyNotesAliasQueryParams = zod.object({
|
|||||||
archived: zod.coerce.boolean().default(listMyNotesAliasQueryArchivedDefault),
|
archived: zod.coerce.boolean().default(listMyNotesAliasQueryArchivedDefault),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListMyNotesAliasResponseItem = zod.record(
|
export const ListMyNotesAliasResponseItem = zod
|
||||||
zod.string(),
|
.object({
|
||||||
zod.unknown(),
|
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);
|
export const ListMyNotesAliasResponse = zod.array(ListMyNotesAliasResponseItem);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user