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
58 changes: 58 additions & 0 deletions e2e/tests/smoke.form-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,64 @@ test.describe("Form Builder Plugin - Admin Pages", () => {
);
});

test("search filters the forms 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 form that matches the search and one that doesn't
const targetSlug = `search-target-form-${testRunId}`;
const otherSlug = `search-other-form-${testRunId}`;
const schema = JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
});
for (const [slug, name] of [
[targetSlug, `Searchable Form ${testRunId}`],
[otherSlug, `Unrelated Form ${testRunId}`],
]) {
const response = await request.post("/api/data/forms", {
headers: { "content-type": "application/json" },
data: { name, slug, schema, status: "active" },
});
expect(
response.ok(),
`Form creation failed with status ${response.status()}`,
).toBe(true);
}

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

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

// Only the matching form 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="form-builder-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("new form page renders with form builder", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
Expand Down
36 changes: 21 additions & 15 deletions packages/stack/registry/btst-form-builder.json

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions packages/stack/src/__tests__/form-builder-query-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* SSG guard: the factory-generated Form Builder query keys must stay
* deep-equal to the `FORM_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 { FORM_QUERY_KEYS } from "../plugins/form-builder/api/query-key-defs";
import { createFormBuilderQueryKeys } from "../plugins/form-builder/query-keys";

const client = vi.fn() as any;

describe("form-builder query keys match SSG prefetch keys", () => {
const queries = createFormBuilderQueryKeys(client);

it("forms list keys match for default params", () => {
expect([...queries.forms.list({}).queryKey]).toEqual([
...FORM_QUERY_KEYS.formsList(),
]);
});

it("forms list keys match for custom limits, offsets and statuses", () => {
expect([
...queries.forms.list({ status: "active", limit: 5, offset: 10 })
.queryKey,
]).toEqual([
...FORM_QUERY_KEYS.formsList({ status: "active", limit: 5, offset: 10 }),
]);
});

it("forms list keys match for search terms", () => {
expect([...queries.forms.list({ search: "contact" }).queryKey]).toEqual([
...FORM_QUERY_KEYS.formsList({ search: "contact" }),
]);
});

it("normalizes a whitespace-only search the same way", () => {
expect([...queries.forms.list({ search: " " }).queryKey]).toEqual([
...FORM_QUERY_KEYS.formsList(),
]);
});

it("form byId keys match", () => {
expect([...queries.forms.byId("abc").queryKey]).toEqual([
...FORM_QUERY_KEYS.formById("abc"),
]);
});

it("submissions list keys match", () => {
expect([
...queries.formSubmissions.list({ formId: "f1", limit: 20, offset: 0 })
.queryKey,
]).toEqual([
...FORM_QUERY_KEYS.submissionsList({
formId: "f1",
limit: 20,
offset: 0,
}),
]);
});

it("exposes the same _def prefixes as the previous factory", () => {
expect([...queries.forms._def]).toEqual(["forms"]);
expect([...queries.forms.list._def]).toEqual(["forms", "list"]);
expect([...queries.forms.byId._def]).toEqual(["forms", "byId"]);
expect([...queries.formSubmissions._def]).toEqual(["formSubmissions"]);
expect([...queries.formSubmissions.list._def]).toEqual([
"formSubmissions",
"list",
]);
});
});
27 changes: 27 additions & 0 deletions packages/stack/src/__tests__/resource-factory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ const resources = {
select: (data: any) => data as { success: boolean },
invalidates: ["items"],
},
// Public-style mutation: success UI is client state, so it must not
// trigger the router refresh (a full reload on public pages)
submit: {
path: "@post/items/:id/submit",
method: "POST" as const,
input: (vars: { id: string }) => ({ params: { id: vars.id } }),
select: (data: any) => data as { success: boolean },
refresh: false,
},
},
},
} satisfies ResourcesDeclaration;
Expand Down Expand Up @@ -516,6 +525,24 @@ describe("createResource hooks", () => {
expect(queryClient.getQueryState(detailKey)?.isInvalidated).toBe(true);
});

it("mutations with refresh: false skip the router refresh", async () => {
fetchMock.mockResolvedValue(jsonResponse({ success: true }));

let captured: any;
function Probe() {
captured = items.items.submit.use();
return null;
}
await render(<Probe />);

await act(async () => {
await captured.mutateAsync({ id: "7" });
});

expect(captured.isSuccess).toBe(true);
expect(refresh).not.toHaveBeenCalled();
});

it("mutations reject with a normalized StackError", async () => {
fetchMock.mockResolvedValue(
jsonResponse(
Expand Down
5 changes: 3 additions & 2 deletions packages/stack/src/plugins/client/resource/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ export function useResourceMutationForDef(
});
}

// Refresh server-side cache (e.g. Next.js router cache)
if (refresh) {
// Refresh server-side cache (e.g. Next.js router cache) unless the
// mutation opts out (public mutations whose success UI is client state)
if (refresh && def.refresh !== false) {
await refresh();
}
},
Expand Down
6 changes: 6 additions & 0 deletions packages/stack/src/plugins/client/resource/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export interface ResourceMutationDef<TVars = any, TResult = unknown> {
query?: string;
args: (result: TResult) => readonly unknown[] | null;
};
/**
* Whether to call the router `refresh` override after success (default
* `true`). Set `false` for mutations that must not reload server-rendered
* state — e.g. public submissions whose success UI lives in client state.
*/
refresh?: boolean;
}

/** Declaration for one resource: its queries and (optionally) mutations. */
Expand Down
Loading
Loading