Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions e2e/tests/smoke.cms.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,63 @@ test.describe("CMS Plugin", () => {
);
});

test("search filters the content list and syncs the URL", async ({
page,
request,
}) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});

// Create one item that matches the search and one that doesn't
const targetSlug = `search-target-${testRunId}`;
const otherSlug = `search-other-${testRunId}`;
for (const [slug, name] of [
[targetSlug, "Searchable Widget"],
[otherSlug, "Unrelated Gadget"],
]) {
await request.post("/api/data/content/product", {
headers: { "content-type": "application/json" },
data: {
slug,
data: {
name,
description: "Product for search test",
price: 10,
featured: false,
category: "Electronics",
},
},
});
}

await page.goto("/pages/cms/product", { waitUntil: "networkidle" });
await expect(page.locator('[data-testid="cms-list-page"]')).toBeVisible();

// Type into the search box; the query is debounced into the URL
await page.locator('[data-testid="cms-list-search"]').fill(targetSlug);
await expect(page).toHaveURL(new RegExp(`q=${targetSlug}`), {
timeout: 10000,
});

// Only the matching item remains in the table
await expect(page.locator(`tr:has-text("${targetSlug}")`)).toBeVisible({
timeout: 30000,
});
await expect(page.locator(`tr:has-text("${otherSlug}")`)).not.toBeVisible();

// Clearing the search restores the full list
await page.locator('[data-testid="cms-list-search"]').fill("");
await expect(page.locator(`tr:has-text("${otherSlug}")`)).toBeVisible({
timeout: 30000,
});

expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual(
[],
);
});

test("slug auto-generation from name field", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
Expand Down
46 changes: 20 additions & 26 deletions packages/stack/registry/btst-cms.json

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions packages/stack/src/__tests__/cms-query-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* SSG guard: the factory-generated CMS query keys must stay deep-equal to
* the `CMS_QUERY_KEYS` builders used by `prefetchForRoute` (DB path).
* Key drift breaks React Query cache hydration silently during `next build`.
*/
import { describe, expect, it, vi } from "vitest";
import { CMS_QUERY_KEYS } from "../plugins/cms/api/query-key-defs";
import { createCMSQueryKeys } from "../plugins/cms/query-keys";

const client = vi.fn() as any;

describe("cms query keys match SSG prefetch keys", () => {
const queries = createCMSQueryKeys(client);

it("types list keys match", () => {
expect([...queries.cmsTypes.list().queryKey]).toEqual([
...CMS_QUERY_KEYS.typesList(),
]);
});

it("content list keys match for default params", () => {
expect([...queries.cmsContent.list({ typeSlug: "post" }).queryKey]).toEqual(
[...CMS_QUERY_KEYS.contentList({ typeSlug: "post" })],
);
});

it("content list keys match for custom limits and offsets", () => {
expect([
...queries.cmsContent.list({ typeSlug: "post", limit: 5, offset: 10 })
.queryKey,
]).toEqual([
...CMS_QUERY_KEYS.contentList({ typeSlug: "post", limit: 5, offset: 10 }),
]);
});

it("content list keys match for search terms", () => {
expect([
...queries.cmsContent.list({ typeSlug: "post", search: "hello" })
.queryKey,
]).toEqual([
...CMS_QUERY_KEYS.contentList({ typeSlug: "post", search: "hello" }),
]);
});

it("normalizes a whitespace-only search the same way", () => {
expect([
...queries.cmsContent.list({ typeSlug: "post", search: " " }).queryKey,
]).toEqual([...CMS_QUERY_KEYS.contentList({ typeSlug: "post" })]);
});

it("content detail keys match", () => {
expect([...queries.cmsContent.detail("post", "abc").queryKey]).toEqual([
...CMS_QUERY_KEYS.contentDetail("post", "abc"),
]);
});

it("exposes the same _def prefixes as the previous factory", () => {
expect([...queries.cmsTypes._def]).toEqual(["cmsTypes"]);
expect([...queries.cmsTypes.list._def]).toEqual(["cmsTypes", "list"]);
expect([...queries.cmsContent.list._def]).toEqual(["cmsContent", "list"]);
});
});
91 changes: 91 additions & 0 deletions packages/stack/src/__tests__/resource-factory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,37 @@ const resources = {
key: () => ["all"],
select: (data: any): Item[] => data ?? [],
},
// Envelope pages ({ items, total }) with a custom nextPageParam
paged: {
path: "/paged/:scope",
params: (_params?: ListParams & { scope?: string }) => ({
scope: _params?.scope ?? "default",
}),
query: (params?: ListParams & { scope?: string }) => ({
limit: params?.limit ?? 10,
}),
key: (params?: ListParams & { scope?: string }) => [
{ scope: params?.scope ?? "default", limit: params?.limit ?? 10 },
],
select: (data: any): { items: Item[]; total: number } => data,
infinite: true,
pageSize: (params?: ListParams & { scope?: string }) =>
params?.limit ?? 10,
nextPageParam: (
lastPage: { items: Item[]; total: number },
allPages: { items: Item[]; total: number }[],
params?: ListParams & { scope?: string },
) => {
const limit = params?.limit ?? 10;
if ((lastPage?.items?.length ?? 0) < limit) return undefined;
const loaded = allPages.reduce(
(sum, page) => sum + (page?.items?.length ?? 0),
0,
);
if (loaded >= (lastPage?.total ?? 0)) return undefined;
return loaded;
},
},
},
mutations: {
create: {
Expand Down Expand Up @@ -144,6 +175,19 @@ describe("createResourceQueryKeys", () => {
});
});

it("passes declared path params to the client", async () => {
client.mockResolvedValue({ data: { items: [], total: 0 } });
const keys = createResourceQueryKeys(client, resources);

await keys.items.paged({ scope: "mine", limit: 5 }).queryFn();

expect(client).toHaveBeenCalledWith("/paged/:scope", {
method: "GET",
params: { scope: "mine" },
query: { limit: 5, offset: 0 },
});
});

it("throws a normalized StackError on error responses", async () => {
client.mockResolvedValue({
error: { message: "denied", status: 403 },
Expand Down Expand Up @@ -368,6 +412,53 @@ describe("createResource hooks", () => {
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("offset=2");
});

it("useInfinite() honors a custom nextPageParam for envelope pages", async () => {
// total 4 with limit 2: page 2 is full (2 items) so the default
// page-size heuristic would keep paging — the custom nextPageParam
// must stop because loaded (4) >= total (4).
const page1 = {
items: [
{ id: "a0", name: "a0" },
{ id: "a1", name: "a1" },
],
total: 4,
};
const page2 = {
items: [
{ id: "b0", name: "b0" },
{ id: "b1", name: "b1" },
],
total: 4,
};
fetchMock.mockImplementation(async (input: any) => {
const url = String(input);
return url.includes("offset=2")
? jsonResponse(page2)
: jsonResponse(page1);
});

let captured: any;
function Probe() {
captured = items.items.paged.useInfinite([{ limit: 2 }]);
return null;
}
await render(<Probe />);
await waitFor(() => captured.isSuccess);

// Envelope survives (not flattened) and total is available
expect(captured.data.pages[0]).toEqual(page1);
expect(captured.hasNextPage).toBe(true);

await act(async () => {
await captured.fetchNextPage();
});
await waitFor(() => captured.data.pages.length === 2);

expect(String(fetchMock.mock.calls[1]?.[0])).toContain("offset=2");
// 4 items loaded >= total 4 — custom nextPageParam reports no more pages
expect(captured.hasNextPage).toBe(false);
});

it("mutations invalidate declared targets, seed detail data and refresh", async () => {
const created: Item = { id: "42", name: "created" };
fetchMock.mockResolvedValue(jsonResponse(created));
Expand Down
3 changes: 3 additions & 0 deletions packages/stack/src/plugins/client/resource/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ function createQueryHooks(
return {
initialPageParam: 0,
getNextPageParam: (lastPage: unknown, allPages: unknown[]) => {
if (def.nextPageParam) {
return def.nextPageParam(lastPage, allPages, ...args);
}
const items = (lastPage as unknown[]) ?? [];
if (items.length < pageSize) return undefined;
return allPages.length * pageSize;
Expand Down
25 changes: 21 additions & 4 deletions packages/stack/src/plugins/client/resource/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ export interface ResourceQueryDef<
TArgs extends readonly unknown[] = readonly any[],
TData = unknown,
> {
/** better-call endpoint path, e.g. `"/posts"` */
/** better-call endpoint path, e.g. `"/posts"` or `"/content/:typeSlug"` */
path: string;
/** Maps hook args to the endpoint path params (for `:param` segments) */
params?: (...args: TArgs) => Record<string, string>;
/** Maps hook args to the HTTP query object */
query?: (...args: TArgs) => Record<string, unknown> | undefined;
/**
Expand All @@ -54,6 +56,17 @@ export interface ResourceQueryDef<
* (default 10). A function form derives it from the hook args.
*/
pageSize?: number | ((...args: TArgs) => number);
/**
* Custom `getNextPageParam` for infinite queries whose pages are not
* plain item arrays (e.g. `{ items, total }` envelopes). Overrides the
* default page-size heuristic. Return `undefined` when there is no
* next page.
*/
nextPageParam?: (
lastPage: TData,
allPages: TData[],
...args: TArgs
) => unknown | undefined;
/** When true, skip fetching and resolve `null` (e.g. missing id) */
skip?: (...args: TArgs) => boolean;
}
Expand Down Expand Up @@ -107,11 +120,13 @@ export type ResourceQueryArgs<TDef> = TDef extends {
query: (...args: infer A) => any;
}
? A
: TDef extends { key: (...args: infer A) => any }
: TDef extends { params: (...args: infer A) => any }
? A
: TDef extends { select: (data: any, ...args: infer A) => any }
: TDef extends { key: (...args: infer A) => any }
? A
: [];
: TDef extends { select: (data: any, ...args: infer A) => any }
? A
: [];

/** Extracts the (per-page, for infinite queries) data type from a query declaration. */
export type ResourceQueryData<TDef> = TDef extends {
Expand Down Expand Up @@ -197,9 +212,11 @@ export async function runResourceQuery(
const query = def.infinite
? { ...baseQuery, [def.offsetParam ?? "offset"]: pageParam ?? 0 }
: baseQuery;
const params = def.params?.(...args);

const response = await client(def.path, {
method: "GET",
...(params !== undefined ? { params } : {}),
...(query !== undefined ? { query } : {}),
...(headers !== undefined ? { headers } : {}),
});
Expand Down
Loading
Loading