feat: add cell formatters and tables for boards, agents, and member invites
This commit is contained in:
79
frontend/src/lib/list-delete.test.ts
Normal file
79
frontend/src/lib/list-delete.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createOptimisticListDeleteMutation } from "./list-delete";
|
||||
|
||||
type Item = { id: string; label: string };
|
||||
type Response = {
|
||||
status: number;
|
||||
data: {
|
||||
items: Item[];
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
describe("createOptimisticListDeleteMutation", () => {
|
||||
it("optimistically removes an item and restores on error", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const key = ["items"];
|
||||
const previous: Response = {
|
||||
status: 200,
|
||||
data: {
|
||||
items: [
|
||||
{ id: "a", label: "A" },
|
||||
{ id: "b", label: "B" },
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
};
|
||||
queryClient.setQueryData(key, previous);
|
||||
|
||||
const callbacks = createOptimisticListDeleteMutation<
|
||||
Item,
|
||||
Response,
|
||||
{ id: string }
|
||||
>({
|
||||
queryClient,
|
||||
queryKey: key,
|
||||
getItemId: (item) => item.id,
|
||||
getDeleteId: ({ id }) => id,
|
||||
});
|
||||
|
||||
const context = await callbacks.onMutate({ id: "a" });
|
||||
const updated = queryClient.getQueryData<Response>(key);
|
||||
|
||||
expect(updated?.data.items.map((item) => item.id)).toEqual(["b"]);
|
||||
expect(updated?.data.total).toBe(1);
|
||||
|
||||
callbacks.onError(new Error("boom"), { id: "a" }, context);
|
||||
expect(queryClient.getQueryData<Response>(key)).toEqual(previous);
|
||||
});
|
||||
|
||||
it("runs success callback and invalidates configured query keys", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const keyA = ["items"];
|
||||
const keyB = ["boards"];
|
||||
const onSuccess = vi.fn();
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
const callbacks = createOptimisticListDeleteMutation<
|
||||
Item,
|
||||
Response,
|
||||
{ id: string }
|
||||
>({
|
||||
queryClient,
|
||||
queryKey: keyA,
|
||||
getItemId: (item) => item.id,
|
||||
getDeleteId: ({ id }) => id,
|
||||
onSuccess,
|
||||
invalidateQueryKeys: [keyA, keyB],
|
||||
});
|
||||
|
||||
callbacks.onSuccess();
|
||||
callbacks.onSettled();
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: keyA });
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: keyB });
|
||||
});
|
||||
});
|
||||
101
frontend/src/lib/list-delete.ts
Normal file
101
frontend/src/lib/list-delete.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
type ListPayload<TItem> = {
|
||||
items: TItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type OptimisticListDeleteContext<TResponse> = {
|
||||
previous?: TResponse;
|
||||
};
|
||||
|
||||
type CreateOptimisticListDeleteMutationOptions<TItem, TVariables> = {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
getItemId: (item: TItem) => string;
|
||||
getDeleteId: (variables: TVariables) => string;
|
||||
onSuccess?: () => void;
|
||||
invalidateQueryKeys?: QueryKey[];
|
||||
};
|
||||
|
||||
function isListPayload<TItem>(value: unknown): value is ListPayload<TItem> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const maybe = value as { items?: unknown; total?: unknown };
|
||||
return Array.isArray(maybe.items) && typeof maybe.total === "number";
|
||||
}
|
||||
|
||||
function getListPayload<TItem>(response: unknown): ListPayload<TItem> | null {
|
||||
if (!response || typeof response !== "object") {
|
||||
return null;
|
||||
}
|
||||
const data = (response as { data?: unknown }).data;
|
||||
return isListPayload<TItem>(data) ? data : null;
|
||||
}
|
||||
|
||||
export function createOptimisticListDeleteMutation<
|
||||
TItem,
|
||||
TResponse extends { status: number },
|
||||
TVariables,
|
||||
>({
|
||||
queryClient,
|
||||
queryKey,
|
||||
getItemId,
|
||||
getDeleteId,
|
||||
onSuccess,
|
||||
invalidateQueryKeys,
|
||||
}: CreateOptimisticListDeleteMutationOptions<TItem, TVariables>) {
|
||||
const keysToInvalidate =
|
||||
invalidateQueryKeys && invalidateQueryKeys.length > 0
|
||||
? invalidateQueryKeys
|
||||
: [queryKey];
|
||||
|
||||
return {
|
||||
onMutate: async (
|
||||
variables: TVariables,
|
||||
): Promise<OptimisticListDeleteContext<TResponse>> => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<TResponse>(queryKey);
|
||||
|
||||
if (previous && previous.status === 200) {
|
||||
const payload = getListPayload<TItem>(previous);
|
||||
if (!payload) {
|
||||
return { previous };
|
||||
}
|
||||
const deleteId = getDeleteId(variables);
|
||||
const nextItems = payload.items.filter(
|
||||
(item) => getItemId(item) !== deleteId,
|
||||
);
|
||||
const removedCount = payload.items.length - nextItems.length;
|
||||
queryClient.setQueryData<TResponse>(queryKey, {
|
||||
...(previous as object),
|
||||
data: {
|
||||
...payload,
|
||||
items: nextItems,
|
||||
total: Math.max(0, payload.total - removedCount),
|
||||
},
|
||||
} as unknown as TResponse);
|
||||
}
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (
|
||||
_error: unknown,
|
||||
_variables: TVariables,
|
||||
context?: OptimisticListDeleteContext<TResponse>,
|
||||
) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(queryKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
},
|
||||
onSettled: () => {
|
||||
for (const key of keysToInvalidate) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
119
frontend/src/lib/use-url-sorting.test.tsx
Normal file
119
frontend/src/lib/use-url-sorting.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { useUrlSorting } from "./use-url-sorting";
|
||||
|
||||
const replaceMock = vi.fn();
|
||||
let mockPathname = "/agents";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
replace: replaceMock,
|
||||
}),
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
describe("useUrlSorting", () => {
|
||||
beforeEach(() => {
|
||||
replaceMock.mockReset();
|
||||
mockPathname = "/agents";
|
||||
window.history.replaceState({}, "", "/agents");
|
||||
});
|
||||
|
||||
it("uses default sorting when no params are present", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUrlSorting({
|
||||
allowedColumnIds: ["name", "status"],
|
||||
defaultSorting: [{ id: "name", desc: false }],
|
||||
paramPrefix: "agents",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sorting).toEqual([{ id: "name", desc: false }]);
|
||||
});
|
||||
|
||||
it("reads sorting from URL params", () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/agents?agents_sort=status&agents_dir=desc",
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUrlSorting({
|
||||
allowedColumnIds: ["name", "status"],
|
||||
defaultSorting: [{ id: "name", desc: false }],
|
||||
paramPrefix: "agents",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sorting).toEqual([{ id: "status", desc: true }]);
|
||||
});
|
||||
|
||||
it("writes updated sorting to URL and preserves unrelated params", () => {
|
||||
window.history.replaceState({}, "", "/agents?foo=1");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUrlSorting({
|
||||
allowedColumnIds: ["name", "status"],
|
||||
defaultSorting: [{ id: "name", desc: false }],
|
||||
paramPrefix: "agents",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onSortingChange([{ id: "status", desc: true }]);
|
||||
});
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledWith(
|
||||
"/agents?foo=1&agents_sort=status&agents_dir=desc",
|
||||
{
|
||||
scroll: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("removes sorting params when returning to default sorting", () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/agents?foo=1&agents_sort=status&agents_dir=desc",
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUrlSorting({
|
||||
allowedColumnIds: ["name", "status"],
|
||||
defaultSorting: [{ id: "name", desc: false }],
|
||||
paramPrefix: "agents",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onSortingChange([{ id: "name", desc: false }]);
|
||||
});
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledWith("/agents?foo=1", {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports explicit no-sorting state via sentinel", () => {
|
||||
window.history.replaceState({}, "", "/agents?agents_sort=none");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUrlSorting({
|
||||
allowedColumnIds: ["name", "status"],
|
||||
defaultSorting: [{ id: "name", desc: false }],
|
||||
paramPrefix: "agents",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sorting).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
result.current.onSortingChange([]);
|
||||
});
|
||||
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
157
frontend/src/lib/use-url-sorting.ts
Normal file
157
frontend/src/lib/use-url-sorting.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
functionalUpdate,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
const SORT_NONE_SENTINEL = "none";
|
||||
|
||||
type UseUrlSortingOptions = {
|
||||
allowedColumnIds: string[];
|
||||
defaultSorting?: SortingState;
|
||||
paramPrefix?: string;
|
||||
};
|
||||
|
||||
type UseUrlSortingResult = {
|
||||
sorting: SortingState;
|
||||
onSortingChange: OnChangeFn<SortingState>;
|
||||
};
|
||||
|
||||
const resolveSortParam = (paramPrefix?: string) =>
|
||||
paramPrefix ? `${paramPrefix}_sort` : "sort";
|
||||
|
||||
const resolveDirectionParam = (paramPrefix?: string) =>
|
||||
paramPrefix ? `${paramPrefix}_dir` : "dir";
|
||||
|
||||
const normalizeSorting = (
|
||||
value: SortingState,
|
||||
allowedColumnIds: Set<string>,
|
||||
): SortingState => {
|
||||
for (const sort of value) {
|
||||
if (!allowedColumnIds.has(sort.id)) continue;
|
||||
return [{ id: sort.id, desc: Boolean(sort.desc) }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const isSameSorting = (a: SortingState, b: SortingState) => {
|
||||
if (a.length !== b.length) return false;
|
||||
if (!a.length) return true;
|
||||
return a[0]?.id === b[0]?.id && Boolean(a[0]?.desc) === Boolean(b[0]?.desc);
|
||||
};
|
||||
|
||||
export function useUrlSorting({
|
||||
allowedColumnIds,
|
||||
defaultSorting = [],
|
||||
paramPrefix,
|
||||
}: UseUrlSortingOptions): UseUrlSortingResult {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [searchParamsString, setSearchParamsString] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
return window.location.search.replace(/^\?/, "");
|
||||
});
|
||||
|
||||
const allowedSet = useMemo(
|
||||
() => new Set(allowedColumnIds),
|
||||
[allowedColumnIds],
|
||||
);
|
||||
const normalizedDefaultSorting = useMemo(
|
||||
() => normalizeSorting(defaultSorting, allowedSet),
|
||||
[defaultSorting, allowedSet],
|
||||
);
|
||||
|
||||
const sortParam = resolveSortParam(paramPrefix);
|
||||
const directionParam = resolveDirectionParam(paramPrefix);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromLocation = () => {
|
||||
setSearchParamsString(window.location.search.replace(/^\?/, ""));
|
||||
};
|
||||
|
||||
syncFromLocation();
|
||||
window.addEventListener("popstate", syncFromLocation);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", syncFromLocation);
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
const sorting = useMemo(() => {
|
||||
const searchParams = new URLSearchParams(searchParamsString);
|
||||
const sortValue = searchParams.get(sortParam);
|
||||
|
||||
if (!sortValue) {
|
||||
return normalizedDefaultSorting;
|
||||
}
|
||||
if (sortValue === SORT_NONE_SENTINEL) {
|
||||
return [];
|
||||
}
|
||||
if (!allowedSet.has(sortValue)) {
|
||||
return normalizedDefaultSorting;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: sortValue,
|
||||
desc: searchParams.get(directionParam) === "desc",
|
||||
},
|
||||
];
|
||||
}, [
|
||||
allowedSet,
|
||||
directionParam,
|
||||
normalizedDefaultSorting,
|
||||
searchParamsString,
|
||||
sortParam,
|
||||
]);
|
||||
|
||||
const onSortingChange = useCallback<OnChangeFn<SortingState>>(
|
||||
(updater) => {
|
||||
const nextSorting = normalizeSorting(
|
||||
functionalUpdate(updater, sorting),
|
||||
allowedSet,
|
||||
);
|
||||
|
||||
if (isSameSorting(nextSorting, sorting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextParams = new URLSearchParams(searchParamsString);
|
||||
|
||||
if (nextSorting.length === 0) {
|
||||
nextParams.set(sortParam, SORT_NONE_SENTINEL);
|
||||
nextParams.delete(directionParam);
|
||||
} else if (isSameSorting(nextSorting, normalizedDefaultSorting)) {
|
||||
nextParams.delete(sortParam);
|
||||
nextParams.delete(directionParam);
|
||||
} else {
|
||||
const [primary] = nextSorting;
|
||||
nextParams.set(sortParam, primary.id);
|
||||
nextParams.set(directionParam, primary.desc ? "desc" : "asc");
|
||||
}
|
||||
|
||||
const query = nextParams.toString();
|
||||
setSearchParamsString(query);
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[
|
||||
allowedSet,
|
||||
directionParam,
|
||||
normalizedDefaultSorting,
|
||||
pathname,
|
||||
router,
|
||||
searchParamsString,
|
||||
sortParam,
|
||||
sorting,
|
||||
],
|
||||
);
|
||||
|
||||
return { sorting, onSortingChange };
|
||||
}
|
||||
Reference in New Issue
Block a user