mirror of
https://github.com/zuiidea/antd-admin
synced 2026-10-31 06:23:47 +00:00
feat(templates): remove orders sample, align basic with with-lingui
- Drop orders routes, API, MSW, and e2e; users-only CRUD template - Port HTTP refresh, hooks, RouteError, and core e2e to basic - Lingui extract --clean in script; refresh catalogs - Update add-resource docs, cursor rule, and optimization spec Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from "react";
|
||||
import type { MessageInstance } from "antd/es/message/interface";
|
||||
|
||||
type MutationLifecycle<TValues> = {
|
||||
onMutate?: (values: TValues) => void;
|
||||
onSuccess?: (values: TValues) => void;
|
||||
onError?: (values: TValues) => void;
|
||||
};
|
||||
|
||||
export type CrudToastLifecycles<TCreate, TUpdate> = {
|
||||
createLifecycle?: MutationLifecycle<TCreate>;
|
||||
updateLifecycle?: MutationLifecycle<TUpdate>;
|
||||
deleteLifecycle?: MutationLifecycle<string>;
|
||||
};
|
||||
|
||||
export function useCrudToasts<TCreate, TUpdate>(options: {
|
||||
message: MessageInstance;
|
||||
resourceKey: string;
|
||||
}): CrudToastLifecycles<TCreate, TUpdate> {
|
||||
const { message, resourceKey } = options;
|
||||
|
||||
const keys = useMemo(
|
||||
() => ({
|
||||
create: `${resourceKey}-mutation-create`,
|
||||
update: `${resourceKey}-mutation-update`,
|
||||
delete: `${resourceKey}-mutation-delete`,
|
||||
}),
|
||||
[resourceKey],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
createLifecycle: {
|
||||
onSuccess: () => {
|
||||
message.success({ content: "Created successfully", key: keys.create });
|
||||
},
|
||||
onError: () => {
|
||||
message.error({ content: "Create failed", key: keys.create });
|
||||
},
|
||||
},
|
||||
updateLifecycle: {
|
||||
onMutate: () => {
|
||||
message.loading({ content: "Updating…", key: keys.update, duration: 0 });
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success({ content: "Updated successfully", key: keys.update });
|
||||
},
|
||||
onError: () => {
|
||||
message.error({ content: "Update failed", key: keys.update });
|
||||
},
|
||||
},
|
||||
deleteLifecycle: {
|
||||
onMutate: () => {
|
||||
message.loading({ content: "Deleting…", key: keys.delete, duration: 0 });
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success({ content: "Deleted successfully", key: keys.delete });
|
||||
},
|
||||
onError: () => {
|
||||
message.error({ content: "Delete failed", key: keys.delete });
|
||||
},
|
||||
},
|
||||
}),
|
||||
[keys.create, keys.delete, keys.update, message],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
|
||||
|
||||
type Row = { id: string; title: string; n: number };
|
||||
|
||||
describe("applyOptimisticListUpdate", () => {
|
||||
it("merges patch into the matching row by id", () => {
|
||||
const data: { list: Row[]; total: number } = {
|
||||
list: [
|
||||
{ id: "1", title: "A", n: 1 },
|
||||
{ id: "2", title: "B", n: 2 },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
|
||||
expect(next.list).toEqual([
|
||||
{ id: "1", title: "A", n: 1 },
|
||||
{ id: "2", title: "B2", n: 2 },
|
||||
]);
|
||||
expect(next.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOptimisticListDelete", () => {
|
||||
it("removes row and decrements total", () => {
|
||||
const data: { list: Row[]; total: number } = {
|
||||
list: [
|
||||
{ id: "1", title: "A", n: 1 },
|
||||
{ id: "2", title: "B", n: 2 },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
const next = applyOptimisticListDelete(data, "1");
|
||||
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
|
||||
expect(next.total).toBe(1);
|
||||
});
|
||||
|
||||
it("does not take total below zero", () => {
|
||||
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
|
||||
const next = applyOptimisticListDelete(data, "1");
|
||||
expect(next.list).toEqual([]);
|
||||
expect(next.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback contract (QueryClient simulation)", () => {
|
||||
it("restore snapshot after failed optimistic update", () => {
|
||||
const key = ["items", 10, 0] as const;
|
||||
const before = {
|
||||
list: [
|
||||
{ id: "1", title: "A", n: 1 },
|
||||
{ id: "2", title: "B", n: 2 },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
const snapshot = structuredClone(before);
|
||||
let cache: typeof before = structuredClone(before);
|
||||
const setData = (next: typeof before) => {
|
||||
cache = next;
|
||||
};
|
||||
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
|
||||
expect(cache.list[1]?.title).toBe("BAD");
|
||||
setData(snapshot);
|
||||
expect(cache).toEqual(before);
|
||||
expect(key[0]).toBe("items");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
|
||||
type MutationLifecycle<TValues> = {
|
||||
onMutate?: (values: TValues) => void;
|
||||
@@ -6,9 +7,23 @@ type MutationLifecycle<TValues> = {
|
||||
onError?: (values: TValues) => void;
|
||||
};
|
||||
|
||||
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
|
||||
export type ResourceListData<TItem extends { id: string }> = {
|
||||
list: readonly TItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
type OptimisticFlags = {
|
||||
update?: boolean;
|
||||
delete?: boolean;
|
||||
};
|
||||
|
||||
type UseResourceCRUDOptions<
|
||||
TListData extends ResourceListData<{ id: string }>,
|
||||
TCreateValues,
|
||||
TUpdateValues extends { id: string },
|
||||
> = {
|
||||
queryKey: readonly unknown[];
|
||||
invalidateKey: readonly unknown[];
|
||||
invalidateKey?: readonly unknown[];
|
||||
queryFn: () => Promise<unknown>;
|
||||
select: (raw: unknown) => TListData;
|
||||
createFn: (values: TCreateValues) => Promise<unknown>;
|
||||
@@ -17,15 +32,63 @@ type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
|
||||
createLifecycle?: MutationLifecycle<TCreateValues>;
|
||||
updateLifecycle?: MutationLifecycle<TUpdateValues>;
|
||||
deleteLifecycle?: MutationLifecycle<string>;
|
||||
optimistic?: OptimisticFlags;
|
||||
};
|
||||
|
||||
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
||||
export type ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> = {
|
||||
data: TListData | undefined;
|
||||
isLoading: boolean;
|
||||
createMutation: UseMutationResult<unknown, Error, TCreateValues>;
|
||||
updateMutation: UseMutationResult<unknown, Error, TUpdateValues>;
|
||||
deleteMutation: UseMutationResult<unknown, Error, string>;
|
||||
};
|
||||
|
||||
/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */
|
||||
export type CrudResult<TListData, TCreateValues, TUpdateValues> = ResourceCRUDResult<
|
||||
TListData,
|
||||
TCreateValues,
|
||||
TUpdateValues
|
||||
>;
|
||||
|
||||
type RollbackCtx<TListData> = { snapshot: TListData | undefined };
|
||||
|
||||
export function applyOptimisticListUpdate<
|
||||
TItem extends { id: string },
|
||||
TUpdate extends { id: string },
|
||||
>(data: ResourceListData<TItem>, values: TUpdate): { list: TItem[]; total: number } {
|
||||
const { id, ...patch } = values as TUpdate & Record<string, unknown>;
|
||||
return {
|
||||
list: data.list.map((row) =>
|
||||
row.id === id ? ({ ...(row as object), ...patch } as TItem) : row,
|
||||
),
|
||||
total: data.total,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOptimisticListDelete<TItem extends { id: string }>(
|
||||
data: ResourceListData<TItem>,
|
||||
id: string,
|
||||
): { list: TItem[]; total: number } {
|
||||
return {
|
||||
list: data.list.filter((row) => row.id !== id),
|
||||
total: Math.max(0, data.total - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function useResourceCRUD<
|
||||
TListData extends ResourceListData<{ id: string }>,
|
||||
TCreateValues,
|
||||
TUpdateValues extends { id: string },
|
||||
>(
|
||||
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
|
||||
) {
|
||||
): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
|
||||
const queryKey = options.queryKey;
|
||||
const opt = options.optimistic;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: options.queryKey,
|
||||
queryKey,
|
||||
queryFn: options.queryFn,
|
||||
select: options.select,
|
||||
});
|
||||
@@ -36,7 +99,7 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
||||
options.createLifecycle?.onMutate?.(values);
|
||||
},
|
||||
onSuccess: (_data, values) => {
|
||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||
options.createLifecycle?.onSuccess?.(values);
|
||||
},
|
||||
onError: (_error, values) => {
|
||||
@@ -46,28 +109,62 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: options.updateFn,
|
||||
onMutate: (values) => {
|
||||
onMutate: async (values): Promise<RollbackCtx<TListData>> => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<TListData>(queryKey);
|
||||
const applied = Boolean(opt?.update && previous);
|
||||
if (applied) {
|
||||
queryClient.setQueryData<TListData>(
|
||||
queryKey,
|
||||
applyOptimisticListUpdate(
|
||||
previous as ResourceListData<{ id: string }>,
|
||||
values as { id: string } & Record<string, unknown>,
|
||||
) as unknown as TListData,
|
||||
);
|
||||
}
|
||||
options.updateLifecycle?.onMutate?.(values);
|
||||
return { snapshot: applied ? previous : undefined };
|
||||
},
|
||||
onSuccess: (_data, values) => {
|
||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||
options.updateLifecycle?.onSuccess?.(values);
|
||||
},
|
||||
onError: (_error, values) => {
|
||||
onError: (_error, values, context) => {
|
||||
const snap = context?.snapshot;
|
||||
if (snap !== undefined) {
|
||||
queryClient.setQueryData(queryKey, snap);
|
||||
}
|
||||
options.updateLifecycle?.onError?.(values);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: options.deleteFn,
|
||||
onMutate: (id) => {
|
||||
onMutate: async (id): Promise<RollbackCtx<TListData>> => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<TListData>(queryKey);
|
||||
const applied = Boolean(opt?.delete && previous);
|
||||
if (applied) {
|
||||
queryClient.setQueryData<TListData>(
|
||||
queryKey,
|
||||
applyOptimisticListDelete(
|
||||
previous as ResourceListData<{ id: string }>,
|
||||
id,
|
||||
) as unknown as TListData,
|
||||
);
|
||||
}
|
||||
options.deleteLifecycle?.onMutate?.(id);
|
||||
return { snapshot: applied ? previous : undefined };
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||
options.deleteLifecycle?.onSuccess?.(id);
|
||||
},
|
||||
onError: (_error, id) => {
|
||||
onError: (_error, id, context) => {
|
||||
const snap = context?.snapshot;
|
||||
if (snap !== undefined) {
|
||||
queryClient.setQueryData(queryKey, snap);
|
||||
}
|
||||
options.deleteLifecycle?.onError?.(id);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
export type UseTableFitHeightArgs = {
|
||||
pageShellRef: RefObject<HTMLDivElement | null>;
|
||||
toolbarRef: RefObject<HTMLDivElement | null>;
|
||||
middleRef: RefObject<HTMLDivElement | null>;
|
||||
tableFrameRef: RefObject<HTMLDivElement | null>;
|
||||
marginLG: number;
|
||||
rowCount: number;
|
||||
isLoading: boolean;
|
||||
showPagination: boolean;
|
||||
};
|
||||
|
||||
export function useTableFitHeight(args: UseTableFitHeightArgs) {
|
||||
const {
|
||||
pageShellRef,
|
||||
toolbarRef,
|
||||
middleRef,
|
||||
tableFrameRef,
|
||||
marginLG,
|
||||
rowCount,
|
||||
isLoading,
|
||||
showPagination,
|
||||
} = args;
|
||||
|
||||
const tableAvailableRef = useRef(0);
|
||||
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
|
||||
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
|
||||
);
|
||||
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
|
||||
return Math.max(120, maxH - 40);
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shell = pageShellRef.current;
|
||||
const toolbarEl = toolbarRef.current;
|
||||
const mid = middleRef.current;
|
||||
if (!shell || !toolbarEl || !mid) return;
|
||||
|
||||
const headReserve = 40;
|
||||
const rowEstimate = 44;
|
||||
const paginationBlock = 56;
|
||||
|
||||
let rafRetry = 0;
|
||||
const maxRafRetries = 8;
|
||||
|
||||
const measure = () => {
|
||||
const toolbarRect = toolbarEl.getBoundingClientRect();
|
||||
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
|
||||
const mainRect = mainEl?.getBoundingClientRect();
|
||||
const viewportBottom =
|
||||
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
|
||||
const clipBottom = mainRect
|
||||
? mainRect.bottom
|
||||
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
|
||||
|
||||
const maxMiddleFromShell = Math.max(
|
||||
0,
|
||||
Math.floor(clipBottom - toolbarRect.bottom - marginLG),
|
||||
);
|
||||
|
||||
const midHRaw = mid.clientHeight;
|
||||
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
|
||||
|
||||
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
|
||||
rafRetry += 1;
|
||||
requestAnimationFrame(() => requestAnimationFrame(measure));
|
||||
return;
|
||||
}
|
||||
|
||||
const frameInner = Math.max(0, Math.floor(midH));
|
||||
tableAvailableRef.current = frameInner;
|
||||
|
||||
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
|
||||
|
||||
const pagReserve = showPagination ? paginationBlock : 0;
|
||||
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
|
||||
|
||||
if (isLoading && rowCount === 0) {
|
||||
setTableScrollY(bodyMax);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowCount === 0 && !isLoading) {
|
||||
setTableScrollY(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const naturalBodyH = headReserve + rowCount * rowEstimate;
|
||||
const naturalTotalH = naturalBodyH + pagReserve;
|
||||
const slack = 12;
|
||||
|
||||
if (naturalTotalH + slack > frameInner) {
|
||||
setTableScrollY(bodyMax);
|
||||
return;
|
||||
}
|
||||
|
||||
setTableScrollY(undefined);
|
||||
requestAnimationFrame(() => {
|
||||
const frame = tableFrameRef.current;
|
||||
const avail = tableAvailableRef.current;
|
||||
const minFrame = headReserve + pagReserve;
|
||||
if (!frame || avail <= minFrame) return;
|
||||
if (frame.scrollHeight > frame.clientHeight + 1) {
|
||||
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(shell);
|
||||
ro.observe(mid);
|
||||
return () => ro.disconnect();
|
||||
}, [
|
||||
showPagination,
|
||||
rowCount,
|
||||
isLoading,
|
||||
marginLG,
|
||||
pageShellRef,
|
||||
toolbarRef,
|
||||
middleRef,
|
||||
tableFrameRef,
|
||||
]);
|
||||
|
||||
return {
|
||||
tableAreaMaxHeight,
|
||||
tableScrollY,
|
||||
lockScrollHeight: tableScrollY != null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
|
||||
search: TSearch;
|
||||
setSearch: (next: TSearch) => void;
|
||||
}) {
|
||||
const { search, setSearch } = args;
|
||||
const [keywordInput, setKeywordInput] = useState(search.keyword);
|
||||
|
||||
useEffect(() => {
|
||||
setKeywordInput(search.keyword);
|
||||
}, [search.keyword]);
|
||||
|
||||
const commitKeyword = useCallback(() => {
|
||||
const trimmed = keywordInput.trim();
|
||||
setSearch({ ...search, keyword: trimmed, offset: 0 });
|
||||
}, [keywordInput, search, setSearch]);
|
||||
|
||||
const applyKeyword = useCallback(
|
||||
(kw: string) => {
|
||||
const trimmed = kw.trim();
|
||||
setKeywordInput(trimmed);
|
||||
setSearch({ ...search, keyword: trimmed, offset: 0 });
|
||||
},
|
||||
[search, setSearch],
|
||||
);
|
||||
|
||||
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
|
||||
}
|
||||
Reference in New Issue
Block a user