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:50:39 +00:00
parent 0bcfb9017d
commit 6826b475b2
4 changed files with 587 additions and 0 deletions
@@ -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
+355
View File
@@ -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<NoteFolder[]> => {
return customFetch<NoteFolder[]>(getListNoteFoldersUrl(params), {
...options,
method: "GET",
});
};
export const getListNoteFoldersQueryKey = (params?: ListNoteFoldersParams) => {
return [`/api/note-folders`, ...(params ? [params] : [])] as const;
};
export const getListNoteFoldersQueryOptions = <
TData = Awaited<ReturnType<typeof listNoteFolders>>,
TError = ErrorType<unknown>,
>(
params?: ListNoteFoldersParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNoteFolders>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListNoteFoldersQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listNoteFolders>>> = ({
signal,
}) => listNoteFolders(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listNoteFolders>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListNoteFoldersQueryResult = NonNullable<
Awaited<ReturnType<typeof listNoteFolders>>
>;
export type ListNoteFoldersQueryError = ErrorType<unknown>;
/**
* @summary List the caller's note folders with per-tab note counts
*/
export function useListNoteFolders<
TData = Awaited<ReturnType<typeof listNoteFolders>>,
TError = ErrorType<unknown>,
>(
params?: ListNoteFoldersParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNoteFolders>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListNoteFoldersQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
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<NoteFolder> => {
return customFetch<NoteFolder>(getCreateNoteFolderUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(createNoteFolderBody),
});
};
export const getCreateNoteFolderMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNoteFolder>>,
TError,
{ data: BodyType<CreateNoteFolderBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createNoteFolder>>,
TError,
{ data: BodyType<CreateNoteFolderBody> },
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<ReturnType<typeof createNoteFolder>>,
{ data: BodyType<CreateNoteFolderBody> }
> = (props) => {
const { data } = props ?? {};
return createNoteFolder(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type CreateNoteFolderMutationResult = NonNullable<
Awaited<ReturnType<typeof createNoteFolder>>
>;
export type CreateNoteFolderMutationBody = BodyType<CreateNoteFolderBody>;
export type CreateNoteFolderMutationError = ErrorType<ErrorResponse>;
/**
* @summary Create a new note folder owned by the caller
*/
export const useCreateNoteFolder = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNoteFolder>>,
TError,
{ data: BodyType<CreateNoteFolderBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof createNoteFolder>>,
TError,
{ data: BodyType<CreateNoteFolderBody> },
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<NoteFolder> => {
return customFetch<NoteFolder>(getUpdateNoteFolderUrl(id), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateNoteFolderBody),
});
};
export const getUpdateNoteFolderMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNoteFolder>>,
TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateNoteFolder>>,
TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> },
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<ReturnType<typeof updateNoteFolder>>,
{ id: number; data: BodyType<UpdateNoteFolderBody> }
> = (props) => {
const { id, data } = props ?? {};
return updateNoteFolder(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateNoteFolderMutationResult = NonNullable<
Awaited<ReturnType<typeof updateNoteFolder>>
>;
export type UpdateNoteFolderMutationBody = BodyType<UpdateNoteFolderBody>;
export type UpdateNoteFolderMutationError = ErrorType<ErrorResponse>;
/**
* @summary Rename a note folder
*/
export const useUpdateNoteFolder = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNoteFolder>>,
TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateNoteFolder>>,
TError,
{ id: number; data: BodyType<UpdateNoteFolderBody> },
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<SuccessResponse> => {
return customFetch<SuccessResponse>(getDeleteNoteFolderUrl(id), {
...options,
method: "DELETE",
});
};
export const getDeleteNoteFolderMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNoteFolder>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteNoteFolder>>,
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<ReturnType<typeof deleteNoteFolder>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return deleteNoteFolder(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteNoteFolderMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteNoteFolder>>
>;
export type DeleteNoteFolderMutationError = ErrorType<unknown>;
/**
* @summary Delete a note folder. Notes inside are preserved (folderId set to null).
*/
export const useDeleteNoteFolder = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNoteFolder>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteNoteFolder>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getDeleteNoteFolderMutationOptions(options));
};
/**
* @summary List notes owned by a user
*/