feat: add cell formatters and tables for boards, agents, and member invites

This commit is contained in:
Abhimanyu Saharan
2026-02-11 11:41:51 +05:30
parent c3490630a4
commit 18d958b3e3
21 changed files with 2618 additions and 1208 deletions

View 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 });
});
});

View 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 });
}
},
};
}

View 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();
});
});

View 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 };
}