From 68b73a3da9fca4c1c5b99349a5beac23380cef35 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:45:09 +0000 Subject: [PATCH 1/3] feat: add createResource factory and migrate blog plugin onto it Adds a core resource-hook factory to @btst/stack/plugins/client: - StackError contract + shared isErrorResponse/toError/SHARED_QUERY_CONFIG, with toError mapping better-call Zod issues to field-level errors - createEndpoint wrapper that preserves Zod validation issues in 400 bodies - server-safe resource declarations + createResourceQueryKeys (SSR/SSG-safe query-key factory with @lukemorales-compatible shapes) - createResource hooks layer (plain/suspense/infinite queries, mutations with declarative invalidation, cache seeding and refresh) via the new client-only @btst/stack/plugins/client/hooks entry - per-resource useForm (create/edit lifecycle, notify, redirect, field errors) and useSelect (debounced search + current-value preload) Migrates the blog plugin as the reference consumer: query-keys.ts and blog-hooks.tsx are thin wrappers over the factory (public hook APIs unchanged, query keys byte-identical), post-forms.tsx drives submit/toast/ field errors through useForm. Part of #129 Co-authored-by: Cursor --- .../skills/btst-client-plugin-dev/SKILL.md | 69 ++- packages/stack/build.config.ts | 1 + packages/stack/package.json | 13 + .../src/__tests__/blog-query-keys.test.ts | 86 ++++ .../src/__tests__/resource-factory.test.tsx | 461 ++++++++++++++++++ .../src/__tests__/resource-use-form.test.tsx | 352 +++++++++++++ .../__tests__/resource-use-select.test.tsx | 248 ++++++++++ .../stack/src/__tests__/stack-error.test.ts | 174 +++++++ .../stack/src/plugins/api/create-endpoint.ts | 82 ++++ packages/stack/src/plugins/api/index.ts | 5 +- .../client/components/forms/post-forms.tsx | 192 +++++--- .../plugins/blog/client/hooks/blog-hooks.tsx | 387 ++------------- .../blog/client/hooks/blog-resource.ts | 13 + .../blog/client/localization/blog-forms.ts | 1 + packages/stack/src/plugins/blog/query-keys.ts | 405 ++++++--------- .../stack/src/plugins/client/hooks/index.tsx | 32 ++ packages/stack/src/plugins/client/index.ts | 30 ++ .../src/plugins/client/resource/errors.ts | 160 ++++++ .../src/plugins/client/resource/hooks.tsx | 282 +++++++++++ .../src/plugins/client/resource/internal.ts | 110 +++++ .../src/plugins/client/resource/queries.ts | 279 +++++++++++ .../plugins/client/resource/use-debounce.ts | 18 + .../src/plugins/client/resource/use-form.ts | 236 +++++++++ .../src/plugins/client/resource/use-select.ts | 196 ++++++++ 24 files changed, 3149 insertions(+), 683 deletions(-) create mode 100644 packages/stack/src/__tests__/blog-query-keys.test.ts create mode 100644 packages/stack/src/__tests__/resource-factory.test.tsx create mode 100644 packages/stack/src/__tests__/resource-use-form.test.tsx create mode 100644 packages/stack/src/__tests__/resource-use-select.test.tsx create mode 100644 packages/stack/src/__tests__/stack-error.test.ts create mode 100644 packages/stack/src/plugins/api/create-endpoint.ts create mode 100644 packages/stack/src/plugins/blog/client/hooks/blog-resource.ts create mode 100644 packages/stack/src/plugins/client/hooks/index.tsx create mode 100644 packages/stack/src/plugins/client/resource/errors.ts create mode 100644 packages/stack/src/plugins/client/resource/hooks.tsx create mode 100644 packages/stack/src/plugins/client/resource/internal.ts create mode 100644 packages/stack/src/plugins/client/resource/queries.ts create mode 100644 packages/stack/src/plugins/client/resource/use-debounce.ts create mode 100644 packages/stack/src/plugins/client/resource/use-form.ts create mode 100644 packages/stack/src/plugins/client/resource/use-select.ts diff --git a/.agents/skills/btst-client-plugin-dev/SKILL.md b/.agents/skills/btst-client-plugin-dev/SKILL.md index 5e10f7b9..e5ed85d7 100644 --- a/.agents/skills/btst-client-plugin-dev/SKILL.md +++ b/.agents/skills/btst-client-plugin-dev/SKILL.md @@ -16,9 +16,73 @@ src/plugins/{name}/ pages/ my-page.tsx ← wrapper: ComposedRoute + lazy import my-page.internal.tsx ← actual UI: useSuspenseQuery - query-keys.ts ← React Query key factory + query-keys.ts ← resource declaration + query key factory ``` +## Data hooks: use `createResource` (new plugins) + +Don't hand-write the useQuery/useMutation + `isErrorResponse`/`toError` plumbing. +Declare the plugin's resources once in `query-keys.ts` and generate everything: + +```typescript +// query-keys.ts (server-safe — no React) +import { createResourceQueryKeys, type ResourcesDeclaration } from "@btst/stack/plugins/client"; + +export const myResources = { + posts: { + queries: { + list: { path: "/posts", query: (p?: ListParams) => ({...}), key: (p?: ListParams) => [discriminator(p)], + select: (d: any): Item[] => d?.items ?? [], infinite: true, pageSize: (p?: ListParams) => p?.limit ?? 10 }, + detail: { path: "/posts", query: (slug: string) => ({ slug, limit: 1 }), key: (slug: string) => [slug], + select: (d: any): Item | null => d?.items?.[0] ?? null, skip: (slug: string) => !slug }, + }, + mutations: { + create: { path: "@post/posts", method: "POST", input: (vars: CreateInput) => ({ body: vars }), + select: (d: any) => d as Item | null, invalidates: ["posts.list"], + setData: { query: "detail", args: (r: Item | null) => (r?.slug ? [r.slug] : null) } }, + }, + }, +} satisfies ResourcesDeclaration; + +// SSR loaders keep using the same factory (keys match @lukemorales shapes) +export function createMyQueryKeys(client, headers?: HeadersInit) { + return createResourceQueryKeys(client, myResources, headers); +} +``` + +```typescript +// client/hooks.ts ("use client") +import { createResource } from "@btst/stack/plugins/client/hooks"; +import { myResources } from "../query-keys"; + +const my = createResource({ plugin: "my-plugin", resources: myResources }); + +export const usePosts = (params?: ListParams) => my.posts.list.useInfinite([params]); +export const useSuspensePost = (slug: string) => my.posts.detail.useSuspense([slug]); +export const useCreatePost = () => my.posts.create.use(); +``` + +Generated per query: `use(args, { enabled? })`, `useSuspense(args)` (plain) or +`useInfinite`/`useSuspenseInfinite` (when `infinite: true`). Suspense variants re-throw +refetch errors automatically. Mutations get `use()` with declarative `invalidates` +(`"resource"` or `"resource.query"` prefixes), optional `setData` cache seeding, and an +awaited `refresh()` after invalidation. Errors are normalized to `StackError` +(`statusCode`, field-level `errors` from Zod issues). + +Each resource also exposes: + +- `my.posts.useForm({ action, id?, record?, defaults?, toCreateVars?, toUpdateVars?, successMessage?, errorMessage?, redirect?, onSuccess? })` + — create/edit lifecycle: fetches the record for edit, runs the right mutation, + notifies via `useNotify()`, redirects via the router adapter, and exposes + `fieldErrors` (map server Zod issues onto react-hook-form with `setError`). +- `my.posts.useSelect({ searchArgs, getOptionValue, getOptionLabel, value?, preload? })` + — debounced server-side search + current-value preloading for relation pickers. + +`SHARED_QUERY_CONFIG`, `isErrorResponse`, `toError`, and `StackError` live in +`@btst/stack/plugins/client` — never copy them into a plugin. The blog plugin +(`src/plugins/blog/query-keys.ts`, `client/hooks/blog-hooks.tsx`) is the reference +consumer. + ## Server/client module boundary `client/plugin.tsx` must stay import-safe on the server. Next.js (including SSG build) @@ -32,6 +96,9 @@ Rules: - Keep `client/plugin.tsx` free of React hooks (`useState`, `useEffect`, etc.). - Put hook utilities in a separate client-only module (`client/hooks.ts`) with `"use client"`, and re-export them from `client/index.ts`. +- `createResource` comes from the client-only entry `@btst/stack/plugins/client/hooks`; + the server-safe `@btst/stack/plugins/client` entry only exposes + `createResourceQueryKeys` + declaration types for `query-keys.ts` and loaders. - UI components can remain client components as needed; only the plugin factory entry must stay server-import-safe. diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 0a5bd1da..ccb10bce 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -80,6 +80,7 @@ export default defineBuildConfig({ // plugin development entries "./src/plugins/api/index.ts", "./src/plugins/client/index.ts", + "./src/plugins/client/hooks/index.tsx", // blog plugin entries "./src/plugins/blog/api/index.ts", "./src/plugins/blog/client/index.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index 2dca0c63..7ac43e56 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -137,6 +137,16 @@ "default": "./dist/plugins/client/index.cjs" } }, + "./plugins/client/hooks": { + "import": { + "types": "./dist/plugins/client/hooks/index.d.ts", + "default": "./dist/plugins/client/hooks/index.mjs" + }, + "require": { + "types": "./dist/plugins/client/hooks/index.d.cts", + "default": "./dist/plugins/client/hooks/index.cjs" + } + }, "./plugins/blog/api": { "import": { "types": "./dist/plugins/blog/api/index.d.ts", @@ -650,6 +660,9 @@ "plugins/client": [ "./dist/plugins/client/index.d.ts" ], + "plugins/client/hooks": [ + "./dist/plugins/client/hooks/index.d.ts" + ], "plugins/blog/api": [ "./dist/plugins/blog/api/index.d.ts" ], diff --git a/packages/stack/src/__tests__/blog-query-keys.test.ts b/packages/stack/src/__tests__/blog-query-keys.test.ts new file mode 100644 index 00000000..8960407e --- /dev/null +++ b/packages/stack/src/__tests__/blog-query-keys.test.ts @@ -0,0 +1,86 @@ +/** + * SSG guard: the factory-generated blog query keys must stay deep-equal to + * the `BLOG_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 { BLOG_QUERY_KEYS } from "../plugins/blog/api/query-key-defs"; +import { createBlogQueryKeys } from "../plugins/blog/query-keys"; + +const client = vi.fn() as any; + +describe("blog query keys match SSG prefetch keys", () => { + const queries = createBlogQueryKeys(client); + + it("posts list keys match for default params", () => { + expect([...queries.posts.list({ published: true }).queryKey]).toEqual([ + ...BLOG_QUERY_KEYS.postsList({ published: true }), + ]); + }); + + it("posts list keys match for drafts and custom limits", () => { + expect([ + ...queries.posts.list({ published: false, limit: 25 }).queryKey, + ]).toEqual([...BLOG_QUERY_KEYS.postsList({ published: false, limit: 25 })]); + }); + + it("posts list keys match for tag filtering", () => { + expect([ + ...queries.posts.list({ published: true, tagSlug: "news" }).queryKey, + ]).toEqual([ + ...BLOG_QUERY_KEYS.postsList({ published: true, tagSlug: "news" }), + ]); + }); + + it("posts list defaults published to true like the SSR loader", () => { + expect([...queries.posts.list().queryKey]).toEqual([ + ...BLOG_QUERY_KEYS.postsList({ published: true }), + ]); + }); + + it("normalizes a whitespace-only query the same way", () => { + expect([ + ...queries.posts.list({ published: true, query: " " }).queryKey, + ]).toEqual([...BLOG_QUERY_KEYS.postsList({ published: true })]); + }); + + it("post detail keys match", () => { + expect([...queries.posts.detail("my-post").queryKey]).toEqual([ + ...BLOG_QUERY_KEYS.postDetail("my-post"), + ]); + }); + + it("tags list keys match", () => { + expect([...queries.tags.list().queryKey]).toEqual([ + ...BLOG_QUERY_KEYS.tagsList(), + ]); + }); + + it("exposes the same _def prefixes as the previous factory", () => { + expect([...queries.posts._def]).toEqual(["posts"]); + expect([...queries.posts.list._def]).toEqual(["posts", "list"]); + expect([...queries.drafts.list._def]).toEqual(["drafts", "list"]); + }); + + it("drafts list keys keep the legacy limit-only shape", () => { + expect([...queries.drafts.list({ limit: 10 }).queryKey]).toEqual([ + "drafts", + "list", + { limit: 10 }, + ]); + expect([...queries.drafts.list().queryKey]).toEqual(["drafts", "list", {}]); + }); + + it("bespoke nextPrevious and recent keys keep their legacy shapes", () => { + const date = new Date("2024-01-01T00:00:00.000Z"); + expect([...queries.posts.nextPrevious(date).queryKey]).toEqual([ + "posts", + "nextPrevious", + "nextPrevious", + date, + ]); + expect([ + ...queries.posts.recent({ limit: 5, excludeSlug: "a" }).queryKey, + ]).toEqual(["posts", "recent", "recent", { limit: 5, excludeSlug: "a" }]); + }); +}); diff --git a/packages/stack/src/__tests__/resource-factory.test.tsx b/packages/stack/src/__tests__/resource-factory.test.tsx new file mode 100644 index 00000000..6fd98521 --- /dev/null +++ b/packages/stack/src/__tests__/resource-factory.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom +import { act, Suspense } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StackProvider } from "../context"; +import { + createResourceQueryKeys, + runResourceMutation, + type ResourcesDeclaration, + type StackError, +} from "../plugins/client"; +import { createResource } from "../plugins/client/hooks"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +interface Item { + id: string; + name: string; +} + +interface ListParams { + q?: string; + limit?: number; +} + +const resources = { + items: { + queries: { + list: { + path: "/items", + query: (params?: ListParams) => ({ + q: params?.q, + limit: params?.limit ?? 10, + }), + key: (params?: ListParams) => [ + { q: params?.q, limit: params?.limit ?? 10 }, + ], + select: (data: any): Item[] => data?.items ?? [], + infinite: true, + pageSize: (params?: ListParams) => params?.limit ?? 10, + }, + detail: { + path: "/items", + query: (id: string) => ({ id, limit: 1 }), + key: (id: string) => [id], + select: (data: any): Item | null => data?.items?.[0] ?? null, + skip: (id: string) => !id, + }, + all: { + path: "/all-items", + key: () => ["all"], + select: (data: any): Item[] => data ?? [], + }, + }, + mutations: { + create: { + path: "@post/items", + method: "POST" as const, + input: (vars: { name: string }) => ({ body: vars }), + select: (data: any) => data as Item | null, + invalidates: ["items.list"], + setData: { + args: (result: Item | null) => (result?.id ? [result.id] : null), + }, + }, + remove: { + path: "@delete/items/:id", + method: "DELETE" as const, + input: (vars: { id: string }) => ({ params: { id: vars.id } }), + select: (data: any) => data as { success: boolean }, + invalidates: ["items"], + }, + }, + }, +} satisfies ResourcesDeclaration; + +describe("createResourceQueryKeys", () => { + const client = vi.fn(); + + beforeEach(() => { + client.mockReset(); + }); + + it("produces query-key-factory compatible shapes", () => { + const keys = createResourceQueryKeys(client, resources); + + expect(keys.items._def).toEqual(["items"]); + expect(keys.items.list._def).toEqual(["items", "list"]); + expect(keys.items.detail._def).toEqual(["items", "detail"]); + + expect(keys.items.list({ q: "x" }).queryKey).toEqual([ + "items", + "list", + { q: "x", limit: 10 }, + ]); + expect(keys.items.detail("42").queryKey).toEqual(["items", "detail", "42"]); + expect(keys.items.all().queryKey).toEqual(["items", "all", "all"]); + }); + + it("defaults key cells to the args when no key fn is declared", () => { + const keys = createResourceQueryKeys(client, { + things: { + queries: { byId: { path: "/things", query: (id: string) => ({ id }) } }, + }, + }); + expect(keys.things.byId("7").queryKey).toEqual(["things", "byId", "7"]); + }); + + it("fetches, unwraps and selects data", async () => { + client.mockResolvedValue({ + data: { items: [{ id: "1", name: "one" }] }, + }); + const keys = createResourceQueryKeys(client, resources, { + "x-test": "yes", + }); + + const result = await keys.items.detail("1").queryFn(); + + expect(result).toEqual({ id: "1", name: "one" }); + expect(client).toHaveBeenCalledWith("/items", { + method: "GET", + query: { id: "1", limit: 1 }, + headers: { "x-test": "yes" }, + }); + }); + + it("injects the page offset for infinite queries", async () => { + client.mockResolvedValue({ data: { items: [] } }); + const keys = createResourceQueryKeys(client, resources); + + await keys.items.list({ q: "a", limit: 5 }).queryFn({ pageParam: 15 }); + + expect(client).toHaveBeenCalledWith("/items", { + method: "GET", + query: { q: "a", limit: 5, offset: 15 }, + }); + + // Defaults to offset 0 when no pageParam is provided + await keys.items.list({ limit: 5 }).queryFn(); + expect(client).toHaveBeenLastCalledWith("/items", { + method: "GET", + query: { q: undefined, limit: 5, offset: 0 }, + }); + }); + + it("throws a normalized StackError on error responses", async () => { + client.mockResolvedValue({ + error: { message: "denied", status: 403 }, + }); + const keys = createResourceQueryKeys(client, resources); + + await expect(keys.items.detail("1").queryFn()).rejects.toMatchObject({ + message: "denied", + statusCode: 403, + }); + }); + + it("skips fetching and resolves null when skip matches", async () => { + const keys = createResourceQueryKeys(client, resources); + + await expect(keys.items.detail("").queryFn()).resolves.toBeNull(); + expect(client).not.toHaveBeenCalled(); + }); +}); + +describe("runResourceMutation", () => { + it("maps vars through input and unwraps the result", async () => { + const client = vi.fn().mockResolvedValue({ data: { success: true } }); + + const result = await runResourceMutation( + client, + resources.items.mutations.remove, + { id: "9" }, + ); + + expect(result).toEqual({ success: true }); + expect(client).toHaveBeenCalledWith("@delete/items/:id", { + method: "DELETE", + params: { id: "9" }, + }); + }); + + it("defaults to sending vars as the body", async () => { + const client = vi.fn().mockResolvedValue({ data: { id: "1" } }); + + await runResourceMutation( + client, + { path: "@post/items", method: "POST" }, + { name: "x" }, + ); + + expect(client).toHaveBeenCalledWith("@post/items", { + method: "POST", + body: { name: "x" }, + }); + }); + + it("throws a normalized StackError with field errors", async () => { + const client = vi.fn().mockResolvedValue({ + error: { + message: "[body.name] Required", + status: 400, + issues: [{ path: ["name"], message: "Required" }], + }, + }); + + try { + await runResourceMutation(client, resources.items.mutations.create, { + name: "", + }); + expect.unreachable("mutation should reject"); + } catch (error) { + const stackError = error as StackError; + expect(stackError.statusCode).toBe(400); + expect(stackError.errors).toEqual({ name: "Required" }); + } + }); +}); + +describe("createResource hooks", () => { + const items = createResource({ plugin: "test-plugin", resources }); + + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + let refresh: ReturnType; + let fetchMock: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient(); + refresh = vi.fn(); + fetchMock = vi.spyOn(globalThis, "fetch" as any); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + } + + async function render(ui: React.ReactElement) { + await act(async () => { + root.render( + + {ui} + , + ); + }); + } + + async function waitFor(check: () => boolean, timeout = 3000) { + const start = Date.now(); + while (!check()) { + if (Date.now() - start > timeout) { + throw new Error("waitFor timed out"); + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + } + + it("use() fetches and selects data", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ items: [{ id: "1", name: "one" }] }), + ); + + let captured: any; + function Probe() { + captured = items.items.detail.use(["1"]); + return null; + } + await render(); + await waitFor(() => captured.isSuccess); + + expect(captured.data).toEqual({ id: "1", name: "one" }); + const url = String(fetchMock.mock.calls[0]?.[0]); + expect(url).toContain("http://test.local/api/data/items"); + expect(url).toContain("id=1"); + }); + + it("use() respects the enabled option", async () => { + let captured: any; + function Probe() { + captured = items.items.detail.use(["1"], { enabled: false }); + return null; + } + await render(); + + expect(captured.fetchStatus).toBe("idle"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("useSuspense() suspends and resolves data", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ items: [{ id: "1", name: "one" }] }), + ); + + let captured: any; + function Probe() { + captured = items.items.detail.useSuspense(["1"]); + return null; + } + await render( + + + , + ); + await waitFor(() => !!captured?.data); + + expect(captured.data).toEqual({ id: "1", name: "one" }); + }); + + it("useInfinite() pages by offset and derives hasNextPage from pageSize", async () => { + const firstPage = Array.from({ length: 2 }, (_, i) => ({ + id: `a${i}`, + name: `a${i}`, + })); + const secondPage = [{ id: "b0", name: "b0" }]; + fetchMock.mockImplementation(async (input: any) => { + const url = String(input); + return url.includes("offset=2") + ? jsonResponse({ items: secondPage }) + : jsonResponse({ items: firstPage }); + }); + + let captured: any; + function Probe() { + captured = items.items.list.useInfinite([{ limit: 2 }]); + return null; + } + await render(); + await waitFor(() => captured.isSuccess); + + expect(captured.data.pages).toEqual([firstPage]); + expect(captured.hasNextPage).toBe(true); + + await act(async () => { + await captured.fetchNextPage(); + }); + await waitFor(() => captured.data.pages.length === 2); + + expect(captured.data.pages[1]).toEqual(secondPage); + // Second page is short (1 < 2) — no further pages + expect(captured.hasNextPage).toBe(false); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain("offset=2"); + }); + + it("mutations invalidate declared targets, seed detail data and refresh", async () => { + const created: Item = { id: "42", name: "created" }; + fetchMock.mockResolvedValue(jsonResponse(created)); + + // Seed a list entry so we can observe invalidation + const listKey = ["items", "list", { q: undefined, limit: 10 }]; + queryClient.setQueryData(listKey, { pages: [[]], pageParams: [0] }); + + let captured: any; + function Probe() { + captured = items.items.create.use(); + return null; + } + await render(); + + await act(async () => { + await captured.mutateAsync({ name: "created" }); + }); + + // POST issued to the right endpoint + const [url, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; + expect(String(url)).toContain("/api/data/items"); + expect(init.method).toBe("POST"); + + // Detail cache seeded from the result + expect(queryClient.getQueryData(["items", "detail", "42"])).toEqual( + created, + ); + // List invalidated + expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true); + // refresh called after invalidation + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("resource-level invalidation targets every query of the resource", async () => { + fetchMock.mockResolvedValue(jsonResponse({ success: true })); + + const listKey = ["items", "list", { q: undefined, limit: 10 }]; + const detailKey = ["items", "detail", "9"]; + queryClient.setQueryData(listKey, { pages: [[]], pageParams: [0] }); + queryClient.setQueryData(detailKey, { id: "9", name: "nine" }); + + let captured: any; + function Probe() { + captured = items.items.remove.use(); + return null; + } + await render(); + + await act(async () => { + await captured.mutateAsync({ id: "9" }); + }); + + expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(detailKey)?.isInvalidated).toBe(true); + }); + + it("mutations reject with a normalized StackError", async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + message: "[body.name] Required", + code: "VALIDATION_ERROR", + issues: [{ path: ["name"], message: "Required" }], + }, + 400, + ), + ); + + let captured: any; + function Probe() { + captured = items.items.create.use(); + return null; + } + await render(); + + let thrown: StackError | undefined; + await act(async () => { + try { + await captured.mutateAsync({ name: "" }); + } catch (error) { + thrown = error as StackError; + } + }); + + expect(thrown).toBeDefined(); + expect(thrown?.errors).toEqual({ name: "Required" }); + // No invalidation or refresh on failure + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stack/src/__tests__/resource-use-form.test.tsx b/packages/stack/src/__tests__/resource-use-form.test.tsx new file mode 100644 index 00000000..fd7d77e4 --- /dev/null +++ b/packages/stack/src/__tests__/resource-use-form.test.tsx @@ -0,0 +1,352 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StackProvider } from "../context"; +import type { ResourcesDeclaration } from "../plugins/client"; +import { createResource } from "../plugins/client/hooks"; +import type { ResourceFormResult } from "../plugins/client/hooks"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +interface Item { + id: string; + slug: string; + name: string; +} + +interface FormValues { + name: string; +} + +const resources = { + items: { + queries: { + detail: { + path: "/items", + query: (slug: string) => ({ slug, limit: 1 }), + key: (slug: string) => [slug], + select: (data: any): Item | null => data?.items?.[0] ?? null, + skip: (slug: string) => !slug, + }, + }, + mutations: { + create: { + path: "@post/items", + method: "POST" as const, + input: (vars: { name: string }) => ({ body: vars }), + select: (data: any) => data as Item | null, + invalidates: ["items"], + }, + update: { + path: "@put/items/:id", + method: "PUT" as const, + input: (vars: { id: string; data: { name: string } }) => ({ + params: { id: vars.id }, + body: vars.data, + }), + select: (data: any) => data as Item | null, + invalidates: ["items"], + }, + }, + }, +} satisfies ResourcesDeclaration; + +const items = createResource({ plugin: "test-plugin", resources }); + +type FormHookConfig = Parameters>[0]; + +describe("resource useForm", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + let navigate: ReturnType; + let refresh: ReturnType; + let notifySuccess: ReturnType; + let notifyError: ReturnType; + let fetchMock: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient(); + navigate = vi.fn(); + refresh = vi.fn(); + notifySuccess = vi.fn(); + notifyError = vi.fn(); + fetchMock = vi.spyOn(globalThis, "fetch" as any); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + } + + async function renderForm( + config: FormHookConfig, + onCapture: (form: ResourceFormResult) => void, + ) { + function Probe() { + const form = items.items.useForm(config); + onCapture(form as ResourceFormResult); + return null; + } + await act(async () => { + root.render( + + + + + , + ); + }); + } + + async function waitFor(check: () => boolean, timeout = 3000) { + const start = Date.now(); + while (!check()) { + if (Date.now() - start > timeout) { + throw new Error("waitFor timed out"); + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + } + + it("create: submits the create mutation, notifies and redirects", async () => { + const created: Item = { id: "1", slug: "one", name: "One" }; + fetchMock.mockResolvedValue(jsonResponse(created)); + + let form!: ResourceFormResult; + await renderForm( + { + action: "create", + successMessage: "Item saved", + redirect: (result) => `/items/${(result as Item).slug}`, + }, + (value) => { + form = value; + }, + ); + + expect(form.record).toBeNull(); + expect(form.isLoadingRecord).toBe(false); + + let result: unknown; + await act(async () => { + result = await form.submit({ name: "One" }); + }); + + expect(result).toEqual(created); + const [url, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; + expect(String(url)).toContain("/api/data/items"); + expect(init.method).toBe("POST"); + expect(notifySuccess).toHaveBeenCalledWith("Item saved"); + expect(navigate).toHaveBeenCalledWith("/items/one"); + expect(refresh).toHaveBeenCalledTimes(1); + expect(form.error).toBeNull(); + expect(form.fieldErrors).toEqual({}); + }); + + it("edit: fetches the record, derives defaults and submits the update mutation", async () => { + const existing: Item = { id: "9", slug: "nine", name: "Nine" }; + fetchMock.mockImplementation(async (input: any, init?: any) => { + void input; + const method = (init as RequestInit | undefined)?.method; + if (!method || method === "GET") { + return jsonResponse({ items: [existing] }); + } + return jsonResponse({ ...existing, name: "Nine v2" }); + }); + + let form!: ResourceFormResult; + await renderForm( + { + action: "edit", + id: "nine", + defaults: (record) => ({ name: (record as Item | null)?.name ?? "" }), + toUpdateVars: (values, record) => ({ + id: (record as Item).id, + data: values, + }), + successMessage: "Item updated", + }, + (value) => { + form = value; + }, + ); + + await waitFor(() => form.record !== null); + expect(form.record).toEqual(existing); + expect(form.defaultValues).toEqual({ name: "Nine" }); + + await act(async () => { + await form.submit({ name: "Nine v2" }); + }); + + const putCall = fetchMock.mock.calls.find( + (call) => (call[1] as RequestInit | undefined)?.method === "PUT", + ); + expect(putCall).toBeDefined(); + expect(String(putCall?.[0])).toContain("/api/data/items/9"); + expect(notifySuccess).toHaveBeenCalledWith("Item updated"); + }); + + it("edit: uses an externally supplied record without fetching", async () => { + const external: Item = { id: "5", slug: "five", name: "Five" }; + + let form!: ResourceFormResult; + await renderForm( + { + action: "edit", + record: external, + defaults: (record) => ({ name: (record as Item | null)?.name ?? "" }), + }, + (value) => { + form = value; + }, + ); + + expect(form.record).toEqual(external); + expect(form.defaultValues).toEqual({ name: "Five" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("maps server validation issues onto fieldErrors without a toast", async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + message: "[body.name] Name is required", + code: "VALIDATION_ERROR", + issues: [{ path: ["name"], message: "Name is required" }], + }, + 400, + ), + ); + + let form!: ResourceFormResult; + await renderForm( + { + action: "create", + successMessage: "saved", + errorMessage: "Failed to save", + }, + (value) => { + form = value; + }, + ); + + let result: unknown = "sentinel"; + await act(async () => { + result = await form.submit({ name: "" }); + }); + + expect(result).toBeUndefined(); + expect(form.fieldErrors).toEqual({ name: "Name is required" }); + expect(form.error?.statusCode).toBe(400); + // Field-level failures do not produce a generic toast + expect(notifyError).not.toHaveBeenCalled(); + expect(notifySuccess).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("notifies errorMessage for non-field errors", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ message: "Internal error" }, 500), + ); + + let form!: ResourceFormResult; + await renderForm( + { + action: "create", + errorMessage: (error) => `Save failed: ${error.message}`, + }, + (value) => { + form = value; + }, + ); + + await act(async () => { + await form.submit({ name: "x" }); + }); + + expect(form.error?.message).toBe("Internal error"); + expect(form.fieldErrors).toEqual({}); + expect(notifyError).toHaveBeenCalledWith("Save failed: Internal error"); + }); + + it("clearErrors resets the error state", async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + message: "invalid", + issues: [{ path: ["name"], message: "Bad" }], + }, + 400, + ), + ); + + let form!: ResourceFormResult; + await renderForm({ action: "create" }, (value) => { + form = value; + }); + + await act(async () => { + await form.submit({ name: "" }); + }); + expect(form.fieldErrors).toEqual({ name: "Bad" }); + + await act(async () => { + form.clearErrors(); + }); + expect(form.error).toBeNull(); + expect(form.fieldErrors).toEqual({}); + }); + + it("skips navigation when redirect resolves falsy", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ id: "1", slug: "one", name: "One" }), + ); + + let form!: ResourceFormResult; + await renderForm( + { + action: "create", + redirect: () => false, + }, + (value) => { + form = value; + }, + ); + + await act(async () => { + await form.submit({ name: "One" }); + }); + + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stack/src/__tests__/resource-use-select.test.tsx b/packages/stack/src/__tests__/resource-use-select.test.tsx new file mode 100644 index 00000000..ec60f21d --- /dev/null +++ b/packages/stack/src/__tests__/resource-use-select.test.tsx @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StackProvider } from "../context"; +import type { ResourcesDeclaration } from "../plugins/client"; +import { createResource } from "../plugins/client/hooks"; +import type { ResourceSelectResult } from "../plugins/client/hooks"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +interface Tag { + id: string; + name: string; +} + +const resources = { + tags: { + queries: { + list: { + path: "/tags", + query: (search?: string) => ({ q: search || undefined }), + key: (search?: string) => [{ q: search || undefined }], + select: (data: any): Tag[] => data ?? [], + }, + detail: { + path: "/tags/one", + query: (id: string) => ({ id }), + key: (id: string) => [id], + select: (data: any): Tag | null => data ?? null, + }, + }, + }, +} satisfies ResourcesDeclaration; + +const tags = createResource({ plugin: "test-plugin", resources }); + +const ALL_TAGS: Tag[] = [ + { id: "1", name: "alpha" }, + { id: "2", name: "beta" }, + { id: "3", name: "kanban" }, +]; + +describe("resource useSelect", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + let fetchMock: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient(); + fetchMock = vi.spyOn(globalThis, "fetch" as any); + fetchMock.mockImplementation(async (input: any) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/tags/one")) { + const id = url.searchParams.get("id"); + const tag = id === "42" ? { id: "42", name: "preloaded" } : null; + return jsonResponse(tag); + } + const q = url.searchParams.get("q"); + const filtered = q + ? ALL_TAGS.filter((tag) => tag.name.includes(q)) + : ALL_TAGS; + return jsonResponse(filtered); + }); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + } + + type SelectConfig = Parameters>[0]; + + async function renderSelect( + config: SelectConfig, + onCapture: (result: ResourceSelectResult) => void, + ) { + function Probe() { + const result = tags.tags.useSelect(config); + onCapture(result); + return null; + } + await act(async () => { + root.render( + + + + + , + ); + }); + } + + async function waitFor(check: () => boolean, timeout = 3000) { + const start = Date.now(); + while (!check()) { + if (Date.now() - start > timeout) { + throw new Error("waitFor timed out"); + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + } + + const baseConfig: SelectConfig = { + searchArgs: (search) => [search], + getOptionValue: (tag) => tag.id, + getOptionLabel: (tag) => tag.name, + debounceMs: 50, + }; + + it("loads initial options for the empty search", async () => { + let select!: ResourceSelectResult; + await renderSelect(baseConfig, (value) => { + select = value; + }); + await waitFor(() => !select.isLoading); + + expect(select.options).toEqual([ + { value: "1", label: "alpha", item: ALL_TAGS[0] }, + { value: "2", label: "beta", item: ALL_TAGS[1] }, + { value: "3", label: "kanban", item: ALL_TAGS[2] }, + ]); + expect(select.isSearching).toBe(false); + }); + + it("debounces the search text and skips intermediate values", async () => { + let select!: ResourceSelectResult; + await renderSelect(baseConfig, (value) => { + select = value; + }); + await waitFor(() => !select.isLoading); + + await act(async () => { + select.setSearch("k"); + }); + await act(async () => { + select.setSearch("ka"); + }); + + // Debounce pending: still searching, no new fetch yet + expect(select.isSearching).toBe(true); + const callsBefore = fetchMock.mock.calls.length; + + await waitFor( + () => !select.isSearching && select.options.length === 1, + 2000, + ); + + expect(select.options[0]?.label).toBe("kanban"); + + // Only the debounced value was fetched — never the intermediate "k" + const searchedTerms = fetchMock.mock.calls + .slice(callsBefore) + .map((call) => new URL(String(call[0])).searchParams.get("q")); + expect(searchedTerms).toEqual(["ka"]); + }); + + it("preloads selected values missing from the options", async () => { + let select!: ResourceSelectResult; + await renderSelect( + { + ...baseConfig, + value: "42", + preload: { args: (value) => [value] }, + }, + (value) => { + select = value; + }, + ); + await waitFor( + () => select.options.some((option) => option.value === "42"), + 2000, + ); + + expect(select.options).toContainEqual({ + value: "42", + label: "preloaded", + item: { id: "42", name: "preloaded" }, + }); + expect(select.selectedOptions).toEqual([ + { + value: "42", + label: "preloaded", + item: { id: "42", name: "preloaded" }, + }, + ]); + }); + + it("does not preload values already present in the options", async () => { + let select!: ResourceSelectResult; + await renderSelect( + { + ...baseConfig, + value: "2", + preload: { args: (value) => [value] }, + }, + (value) => { + select = value; + }, + ); + await waitFor(() => !select.isLoading); + + expect(select.selectedOptions).toEqual([ + { value: "2", label: "beta", item: ALL_TAGS[1] }, + ]); + const detailCalls = fetchMock.mock.calls.filter((call) => + String(call[0]).includes("/tags/one"), + ); + expect(detailCalls).toHaveLength(0); + }); + + it("falls back to the raw value for unresolvable selections", async () => { + let select!: ResourceSelectResult; + await renderSelect({ ...baseConfig, value: "missing" }, (value) => { + select = value; + }); + await waitFor(() => !select.isLoading); + + expect(select.selectedOptions).toEqual([ + { value: "missing", label: "missing" }, + ]); + }); +}); diff --git a/packages/stack/src/__tests__/stack-error.test.ts b/packages/stack/src/__tests__/stack-error.test.ts new file mode 100644 index 00000000..9c2318d4 --- /dev/null +++ b/packages/stack/src/__tests__/stack-error.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { createRouter } from "better-call"; +import { createEndpoint } from "../plugins/api"; +import { isErrorResponse, toError, type StackError } from "../plugins/client"; + +describe("isErrorResponse", () => { + it("detects better-call error responses", () => { + expect(isErrorResponse({ error: { message: "boom" } })).toBe(true); + expect(isErrorResponse({ error: null, data: [] })).toBe(false); + expect(isErrorResponse({ data: [] })).toBe(false); + expect(isErrorResponse(null)).toBe(false); + expect(isErrorResponse("nope")).toBe(false); + }); +}); + +describe("toError", () => { + it("passes through Error instances", () => { + const original = new Error("original"); + expect(toError(original)).toBe(original); + }); + + it("extracts message from object errors and preserves properties", () => { + const error = toError({ message: "boom", code: "SOME_CODE" }); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("boom"); + expect((error as StackError & { code?: string }).code).toBe("SOME_CODE"); + }); + + it("falls back to the error string property, then JSON", () => { + expect(toError({ error: "denied" }).message).toBe("denied"); + expect(toError({ foo: 1 }).message).toBe('{"foo":1}'); + }); + + it("wraps primitive values", () => { + expect(toError("nope").message).toBe("nope"); + expect(toError(42).message).toBe("42"); + }); + + it("normalizes statusCode from status or statusCode", () => { + expect(toError({ message: "x", status: 404 }).statusCode).toBe(404); + expect(toError({ message: "x", statusCode: 403 }).statusCode).toBe(403); + expect(toError({ message: "x" }).statusCode).toBeUndefined(); + }); + + it("maps validation issues to field errors", () => { + const error = toError({ + message: "[body.title] Required; [body.tags.0.name] Too short", + code: "VALIDATION_ERROR", + issues: [ + { path: ["title"], message: "Required" }, + { path: ["tags", 0, "name"], message: "Too short" }, + ], + }); + expect(error.errors).toEqual({ + title: "Required", + "tags.0.name": "Too short", + }); + }); + + it("collects multiple issues on the same field into an array", () => { + const error = toError({ + message: "invalid", + issues: [ + { path: ["slug"], message: "Too short" }, + { path: ["slug"], message: "Invalid characters" }, + ], + }); + expect(error.errors).toEqual({ + slug: ["Too short", "Invalid characters"], + }); + }); + + it("skips issues without a path", () => { + const error = toError({ + message: "invalid", + issues: [{ path: [], message: "Something is wrong" }], + }); + expect(error.errors).toBeUndefined(); + }); + + it("keeps a pre-shaped errors record", () => { + const error = toError({ + message: "invalid", + errors: { title: "Required", tags: ["a", "b"] }, + }); + expect(error.errors).toEqual({ title: "Required", tags: ["a", "b"] }); + }); + + it("drops a non-conforming errors property", () => { + const error = toError({ message: "invalid", errors: 42 }); + expect(error.errors).toBeUndefined(); + }); +}); + +describe("createEndpoint validation issue preservation", () => { + const createItem = createEndpoint( + "/items", + { + method: "POST", + body: z.object({ + title: z.string().min(1, "Title is required"), + count: z.number(), + }), + }, + async (ctx) => ctx.body, + ); + + it("includes serialized Zod issues in the 400 response body", async () => { + const router = createRouter({ createItem }); + const response = await router.handler( + new Request("http://localhost/items", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "", count: "not-a-number" }), + }), + ); + + expect(response.status).toBe(400); + const body = (await response.json()) as Record; + expect(body.code).toBe("VALIDATION_ERROR"); + expect(Array.isArray(body.issues)).toBe(true); + + // Round-trip: the response body maps onto StackError field errors + const error = toError({ ...body, status: response.status }); + expect(error.statusCode).toBe(400); + expect(error.errors).toBeDefined(); + expect(error.errors?.title).toBe("Title is required"); + expect(error.errors?.count).toBeDefined(); + }); + + it("keeps valid requests working", async () => { + const router = createRouter({ createItem }); + const response = await router.handler( + new Request("http://localhost/items", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "hello", count: 2 }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ title: "hello", count: 2 }); + }); + + it("respects a user-provided onValidationError", async () => { + let observed: unknown; + const custom = createEndpoint( + "/custom", + { + method: "POST", + body: z.object({ name: z.string() }), + onValidationError: (error: unknown) => { + observed = error; + }, + }, + async (ctx) => ctx.body, + ); + const router = createRouter({ custom }); + const response = await router.handler( + new Request("http://localhost/custom", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + ); + + // Default better-call behavior: 400 without issues in the body + expect(response.status).toBe(400); + const body = (await response.json()) as Record; + expect(body.issues).toBeUndefined(); + expect(observed).toBeDefined(); + }); +}); diff --git a/packages/stack/src/plugins/api/create-endpoint.ts b/packages/stack/src/plugins/api/create-endpoint.ts new file mode 100644 index 00000000..389e303b --- /dev/null +++ b/packages/stack/src/plugins/api/create-endpoint.ts @@ -0,0 +1,82 @@ +import { APIError, createEndpoint as baseCreateEndpoint } from "better-call"; + +/** + * Validation issue segment shapes produced by standard-schema validators + * (better-call runs Zod through the standard-schema interface). + */ +type IssuePathSegment = PropertyKey | { key: PropertyKey }; + +interface StandardIssue { + message: string; + path?: ReadonlyArray; +} + +/** JSON-safe issue shape included in 400 validation error response bodies. */ +export interface SerializedValidationIssue { + message: string; + path: Array; +} + +function serializeIssues(issues: unknown): SerializedValidationIssue[] { + if (!Array.isArray(issues)) return []; + return (issues as StandardIssue[]) + .filter((issue) => typeof issue?.message === "string") + .map((issue) => ({ + message: issue.message, + path: (issue.path ?? []).map((segment) => { + const key = + typeof segment === "object" && segment !== null + ? segment.key + : segment; + return typeof key === "number" ? key : String(key); + }), + })); +} + +/** + * Drop-in replacement for better-call's `createEndpoint` that preserves + * Zod field-level validation issues in error responses. + * + * better-call discards `ValidationError.issues` when building the default + * 400 response (only the flattened message survives). Its `onValidationError` + * callback runs before that default and may throw a replacement error, so we + * inject one that re-throws the same 400 `APIError` with a JSON-safe `issues` + * array added to the body. Clients can then map issues onto form fields (see + * `toError` in `@btst/stack/plugins/client`). + * + * Endpoints that define their own `onValidationError` are left untouched. + */ +export const createEndpoint = (( + pathOrOptions: any, + handlerOrOptions: any, + handlerOrNever?: any, +) => { + const isPathForm = typeof pathOrOptions === "string"; + const options = isPathForm ? handlerOrOptions : pathOrOptions; + + const wrappedOptions = + options && typeof options === "object" && !options.onValidationError + ? { + ...options, + onValidationError: ({ + message, + issues, + }: { + message: string; + issues: unknown; + }) => { + throw new APIError(400, { + message, + code: "VALIDATION_ERROR", + issues: serializeIssues(issues), + }); + }, + } + : options; + + return isPathForm + ? baseCreateEndpoint(pathOrOptions, wrappedOptions, handlerOrNever) + : baseCreateEndpoint(wrappedOptions, handlerOrOptions); +}) as typeof baseCreateEndpoint; + +createEndpoint.create = baseCreateEndpoint.create; diff --git a/packages/stack/src/plugins/api/index.ts b/packages/stack/src/plugins/api/index.ts index 0ef81180..e907cb64 100644 --- a/packages/stack/src/plugins/api/index.ts +++ b/packages/stack/src/plugins/api/index.ts @@ -26,7 +26,10 @@ export type { // Re-export Better Call functions needed for plugins export type { Endpoint, Router } from "better-call"; -export { createEndpoint, createRouter } from "better-call"; +export { createRouter } from "better-call"; +// Wrapped createEndpoint that preserves Zod validation issues in 400 responses +export { createEndpoint } from "./create-endpoint"; +export type { SerializedValidationIssue } from "./create-endpoint"; export { createDbPlugin } from "@btst/db"; /** diff --git a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx index a98ad202..1f25a55a 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx @@ -19,13 +19,10 @@ import { Input } from "@workspace/ui/components/input"; import { Switch } from "@workspace/ui/components/switch"; import { Textarea } from "@workspace/ui/components/textarea"; -import { - useCreatePost, - useSuspensePost, - useUpdatePost, - useDeletePost, -} from "../../hooks/blog-hooks"; +import { useSuspensePost, useDeletePost } from "../../hooks/blog-hooks"; +import { blog } from "../../hooks/blog-resource"; import { slugify } from "../../../utils"; +import type { SerializedPost } from "../../../types"; import { AlertDialog, AlertDialogAction, @@ -40,14 +37,14 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; -import { lazy, memo, Suspense, useEffect, useMemo, useState } from "react"; +import { lazy, memo, Suspense, useEffect, useState } from "react"; import { type FieldPath, + type FieldValues, type SubmitHandler, type UseFormReturn, useForm, } from "react-hook-form"; -import { toast } from "sonner"; import { z } from "zod"; import { FeaturedImageField } from "./image-field"; @@ -57,11 +54,29 @@ const MarkdownEditor = lazy(() => })), ); import { BLOG_LOCALIZATION } from "../../localization"; -import { usePluginOverrides } from "@btst/stack/context"; +import { useNotify, usePluginOverrides } from "@btst/stack/context"; import type { BlogPluginOverrides } from "../../overrides"; import { EmptyList } from "../shared/empty-list"; import { TagsMultiSelect } from "./tags-multiselect"; +/** + * Applies server-side field validation errors (from `StackError.errors`) + * onto react-hook-form field state. + */ +function useServerFieldErrors( + form: UseFormReturn, + fieldErrors: Record, +) { + useEffect(() => { + for (const [field, message] of Object.entries(fieldErrors)) { + form.setError(field as FieldPath, { + type: "server", + message: Array.isArray(message) ? message.join(", ") : message, + }); + } + }, [fieldErrors, form]); +} + type CommonPostFormValues = { title: string; content: string; @@ -358,33 +373,33 @@ const AddPostFormComponent = ({ const schema = CustomPostCreateSchema; - const { - mutateAsync: createPost, - isPending: isCreatingPost, - error: createPostError, - } = useCreatePost(); - type AddPostFormValues = z.input; - const onSubmit = async (data: AddPostFormValues) => { - // Auto-generate slug from title if not provided - const slug = data.slug || slugify(data.title); - // Wait for mutation to complete, including refresh - const createdPost = await createPost({ + const resourceForm = blog.posts.useForm< + AddPostFormValues, + SerializedPost | null + >({ + action: "create", + successMessage: localization.BLOG_FORMS_TOAST_CREATE_SUCCESS, + toCreateVars: (data) => ({ title: data.title, content: data.content, excerpt: data.excerpt ?? "", - slug, + // Auto-generate slug from title if not provided + slug: data.slug || slugify(data.title), published: data.published ?? false, publishedAt: data.published ? new Date() : undefined, image: data.image, tags: data.tags || [], - }); - - toast.success(localization.BLOG_FORMS_TOAST_CREATE_SUCCESS); + }), + onSuccess: (createdPost) => { + // Navigate only after mutation (including invalidation) completes + onSuccess({ published: createdPost?.published ?? false }); + }, + }); - // Navigate only after mutation completes - onSuccess({ published: createdPost?.published ?? false }); + const onSubmit = async (data: AddPostFormValues) => { + await resourceForm.submit(data); }; // For compatibility with resolver types that require certain required fields, @@ -402,6 +417,10 @@ const AddPostFormComponent = ({ }, }); + // Server-side Zod validation failures land on the matching form fields + useServerFieldErrors(form, resourceForm.fieldErrors); + const hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0; + // Expose form instance to parent for AI context integration useEffect(() => { onFormReady?.(form); @@ -413,13 +432,13 @@ const AddPostFormComponent = ({ form={form} onSubmit={onSubmit} submitLabel={ - isCreatingPost + resourceForm.isSubmitting ? localization.BLOG_FORMS_SUBMIT_CREATE_PENDING : localization.BLOG_FORMS_SUBMIT_CREATE_IDLE } onCancel={onClose} - disabled={isCreatingPost || featuredImageUploading} - errorMessage={createPostError?.message} + disabled={resourceForm.isSubmitting || featuredImageUploading} + errorMessage={hasFieldErrors ? undefined : resourceForm.error?.message} setFeaturedImageUploading={setFeaturedImageUploading} /> ); @@ -468,72 +487,85 @@ const EditPostFormComponent = ({ // const { uploadImage } = useBlogContext() const { post } = useSuspensePost(postSlug); - - const initialData = useMemo(() => { - if (!post) return {}; - return { - title: post.title, - content: post.content, - excerpt: post.excerpt, - slug: post.slug, - published: post.published, - image: post.image || "", - tags: post.tags.map((tag) => ({ - id: tag.id, - name: tag.name, - slug: tag.slug, - })), - }; - }, [post]); + const notify = useNotify(); const schema = CustomPostUpdateSchema; - const { - mutateAsync: updatePost, - isPending: isUpdatingPost, - error: updatePostError, - } = useUpdatePost(); - - const { mutateAsync: deletePost, isPending: isDeletingPost } = - useDeletePost(); - type EditPostFormValues = z.input; - const onSubmit = async (data: EditPostFormValues) => { - // Wait for mutation to complete, including refresh - const updatedPost = await updatePost({ - id: post!.id, + + const resourceForm = blog.posts.useForm< + EditPostFormValues, + SerializedPost | null + >({ + action: "edit", + // Record comes from the suspense hook above — skips useForm's own fetch + record: post, + successMessage: localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS, + defaults: (record) => + (record + ? { + title: record.title, + content: record.content, + excerpt: record.excerpt, + slug: record.slug, + published: record.published, + image: record.image || "", + tags: record.tags.map((tag) => ({ + id: tag.id, + name: tag.name, + slug: tag.slug, + })), + } + : {}) as EditPostFormValues, + toUpdateVars: (data, record) => ({ + id: (record as SerializedPost).id, data: { - id: post!.id, + id: (record as SerializedPost).id, title: data.title, content: data.content, excerpt: data.excerpt ?? "", slug: data.slug, published: data.published ?? false, publishedAt: - data.published && !post?.published + data.published && !record?.published ? new Date() - : post?.publishedAt - ? new Date(post.publishedAt) + : record?.publishedAt + ? new Date(record.publishedAt) : undefined, image: data.image, tags: data.tags || [], }, - }); + }), + onSuccess: (updatedPost) => { + // Navigate only after mutation (including invalidation) completes + onSuccess({ + slug: updatedPost?.slug ?? "", + published: updatedPost?.published ?? false, + }); + }, + }); - toast.success(localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS); + const { mutateAsync: deletePost, isPending: isDeletingPost } = + useDeletePost(); - // Navigate only after mutation completes - onSuccess({ - slug: updatedPost?.slug ?? "", - published: updatedPost?.published ?? false, - }); + const onSubmit = async (data: EditPostFormValues) => { + await resourceForm.submit(data); }; const handleDelete = async () => { if (!post?.id) return; - await deletePost({ id: post.id }); - toast.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS); + try { + await deletePost({ id: post.id }); + } catch (error) { + notify.error( + error instanceof Error + ? error.message + : localization.BLOG_FORMS_TOAST_DELETE_FAILURE, + ); + return; + } + notify.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS); setDeleteDialogOpen(false); // Call onDelete callback if provided, otherwise use onClose @@ -555,9 +587,13 @@ const EditPostFormComponent = ({ image: "", tags: [], }, - values: initialData as z.input, + values: resourceForm.defaultValues as z.input, }); + // Server-side Zod validation failures land on the matching form fields + useServerFieldErrors(form, resourceForm.fieldErrors); + const hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0; + // Expose form instance to parent for AI context integration useEffect(() => { onFormReady?.(form); @@ -574,13 +610,13 @@ const EditPostFormComponent = ({ form={form} onSubmit={onSubmit} submitLabel={ - isUpdatingPost + resourceForm.isSubmitting ? localization.BLOG_FORMS_SUBMIT_UPDATE_PENDING : localization.BLOG_FORMS_SUBMIT_UPDATE_IDLE } onCancel={onClose} - disabled={isUpdatingPost || featuredImageUploading} - errorMessage={updatePostError?.message} + disabled={resourceForm.isSubmitting || featuredImageUploading} + errorMessage={hasFieldErrors ? undefined : resourceForm.error?.message} setFeaturedImageUploading={setFeaturedImageUploading} initialSlugTouched={!!post?.slug} /> @@ -591,7 +627,9 @@ const EditPostFormComponent = ({ variant="destructive" type="button" disabled={ - isUpdatingPost || featuredImageUploading || isDeletingPost + resourceForm.isSubmitting || + featuredImageUploading || + isDeletingPost } className="mt-4" > diff --git a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx index eb8881a7..ceb2f5ae 100644 --- a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx +++ b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx @@ -1,37 +1,11 @@ "use client"; -import { createApiClient } from "@btst/stack/plugins/client"; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, - useSuspenseInfiniteQuery, - useSuspenseQuery, - type InfiniteData, -} from "@tanstack/react-query"; -import type { SerializedPost, SerializedTag } from "../../types"; -import type { BlogApiRouter } from "../../api/plugin"; -import { useDebounce } from "./use-debounce"; import { useEffect, useRef } from "react"; import { z } from "zod"; -import { createPostSchema, updatePostSchema } from "../../schemas"; -import { createBlogQueryKeys } from "../../query-keys"; -import { usePluginOverrides } from "@btst/stack/context"; -import type { BlogPluginOverrides } from "../overrides"; - -/** - * Shared React Query configuration for all blog queries - * Prevents automatic refetching to avoid hydration mismatches in SSR - */ -const SHARED_QUERY_CONFIG = { - retry: false, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 10, // 10 minutes -} as const; +import type { SerializedPost, SerializedTag } from "../../types"; +import type { createPostSchema, updatePostSchema } from "../../schemas"; +import { useDebounce } from "./use-debounce"; +import { blog } from "./blog-resource"; /** * Options for the usePosts hook @@ -132,31 +106,7 @@ export type PostUpdateInput = z.infer; * Hook for fetching paginated posts with load more functionality */ export function usePosts(options: UsePostsOptions = {}): UsePostsResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const { - tag, - tagSlug, - limit = 10, - enabled = true, - query, - published, - } = options; - const queries = createBlogQueryKeys(client, headers); - - const queryParams = { - tag, - tagSlug, - limit, - query, - published, - }; - - const basePosts = queries.posts.list(queryParams); + const { tagSlug, limit = 10, enabled = true, query, published } = options; const { data, @@ -166,21 +116,11 @@ export function usePosts(options: UsePostsOptions = {}): UsePostsResult { hasNextPage, isFetchingNextPage, refetch, - } = useInfiniteQuery({ - ...basePosts, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - const posts = lastPage as SerializedPost[]; - if (posts.length < limit) return undefined; - return allPages.length * limit; - }, - enabled: enabled && !!client, + } = blog.posts.list.useInfinite([{ tagSlug, limit, query, published }], { + enabled, }); - const posts = (( - data as InfiniteData | undefined - )?.pages?.flat() ?? []) as SerializedPost[]; + const posts = data?.pages?.flat() ?? []; return { posts, @@ -201,51 +141,12 @@ export function useSuspensePosts(options: UsePostsOptions = {}): { isLoadingMore: boolean; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const { - tag, - tagSlug, - limit = 10, - enabled = true, - query, - published, - } = options; - const queries = createBlogQueryKeys(client, headers); - - const queryParams = { tag, tagSlug, limit, query, published }; - const basePosts = queries.posts.list(queryParams); - - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - refetch, - error, - isFetching, - } = useSuspenseInfiniteQuery({ - ...basePosts, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - const posts = lastPage as SerializedPost[]; - if (posts.length < limit) return undefined; - return allPages.length * limit; - }, - }); + const { tagSlug, limit = 10, query, published } = options; - // Manually throw errors for Error Boundaries (per React Query Suspense docs) - // useSuspenseQuery only throws errors if there's no data, but we want to throw always - if (error && !isFetching) { - throw error; - } + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = + blog.posts.list.useSuspenseInfinite([{ tagSlug, limit, query, published }]); - const posts = (data.pages?.flat() ?? []) as SerializedPost[]; + const posts = data.pages?.flat() ?? []; return { posts, @@ -260,25 +161,10 @@ export function useSuspensePosts(options: UsePostsOptions = {}): { * Hook for fetching a single post by slug */ export function usePost(slug?: string): UsePostResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createBlogQueryKeys(client, headers); - - const basePost = queries.posts.detail(slug ?? ""); - const { data, isLoading, error, refetch } = useQuery< - SerializedPost | null, - Error, - SerializedPost | null, - typeof basePost.queryKey - >({ - ...basePost, - ...SHARED_QUERY_CONFIG, - enabled: !!client && !!slug, - }); + const { data, isLoading, error, refetch } = blog.posts.detail.use( + [slug ?? ""], + { enabled: !!slug }, + ); return { post: data || null, @@ -293,29 +179,7 @@ export function useSuspensePost(slug: string): { post: SerializedPost | null; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createBlogQueryKeys(client, headers); - const basePost = queries.posts.detail(slug); - const { data, refetch, error, isFetching } = useSuspenseQuery< - SerializedPost | null, - Error, - SerializedPost | null, - typeof basePost.queryKey - >({ - ...basePost, - ...SHARED_QUERY_CONFIG, - }); - - // Manually throw errors for Error Boundaries (per React Query Suspense docs) - // useSuspenseQuery only throws errors if there's no data, but we want to throw always - if (error && !isFetching) { - throw error; - } + const { data, refetch } = blog.posts.detail.useSuspense([slug]); return { post: data || null, refetch }; } @@ -329,24 +193,7 @@ export function useTags(): { error: Error | null; refetch: () => void; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createBlogQueryKeys(client, headers); - const baseTags = queries.tags.list(); - const { data, isLoading, error, refetch } = useQuery< - SerializedTag[] | null, - Error, - SerializedTag[] | null, - typeof baseTags.queryKey - >({ - ...baseTags, - ...SHARED_QUERY_CONFIG, - enabled: !!client, - }); + const { data, isLoading, error, refetch } = blog.tags.list.use([]); return { tags: data ?? [], @@ -361,29 +208,7 @@ export function useSuspenseTags(): { tags: SerializedTag[]; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createBlogQueryKeys(client, headers); - const baseTags = queries.tags.list(); - const { data, refetch, error, isFetching } = useSuspenseQuery< - SerializedTag[] | null, - Error, - SerializedTag[] | null, - typeof baseTags.queryKey - >({ - ...baseTags, - ...SHARED_QUERY_CONFIG, - }); - - // Manually throw errors for Error Boundaries (per React Query Suspense docs) - // useSuspenseQuery only throws errors if there's no data, but we want to throw always - if (error && !isFetching) { - throw error; - } + const { data, refetch } = blog.tags.list.useSuspense([]); return { tags: data ?? [], @@ -393,133 +218,17 @@ export function useSuspenseTags(): { /** Create a new post */ export function useCreatePost() { - const { refresh, apiBaseURL, apiBasePath } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createBlogQueryKeys(client); - - return useMutation({ - mutationKey: [...queries.posts._def, "create"], - mutationFn: async (postData: PostCreateInput) => { - const response = await client("@post/posts", { - method: "POST", - body: postData, - }); - return response.data as SerializedPost | null; - }, - onSuccess: async (created) => { - // Update detail cache if available - if (created?.slug) { - queryClient.setQueryData( - queries.posts.detail(created.slug).queryKey, - created, - ); - } - // Invalidate lists scoped to posts and drafts - wait for completion - await queryClient.invalidateQueries({ - queryKey: queries.posts.list._def, - }); - await queryClient.invalidateQueries({ - queryKey: queries.drafts.list._def, - }); - // Refresh server-side cache (Next.js router cache) - if (refresh) { - await refresh(); - } - }, - }); + return blog.posts.create.use(); } /** Update an existing post by id */ export function useUpdatePost() { - const { refresh, apiBaseURL, apiBasePath } = - usePluginOverrides("blog"); - - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - - const queryClient = useQueryClient(); - const queries = createBlogQueryKeys(client); - - return useMutation< - SerializedPost | null, - Error, - { id: string; data: PostUpdateInput } - >({ - mutationKey: [...queries.posts._def, "update"], - mutationFn: async ({ id, data }: { id: string; data: PostUpdateInput }) => { - const response = await client(`@put/posts/:id`, { - method: "PUT", - params: { id }, - body: data, - }); - return response.data as SerializedPost | null; - }, - onSuccess: async (updated) => { - // Update detail cache if available - if (updated?.slug) { - queryClient.setQueryData( - queries.posts.detail(updated.slug).queryKey, - updated, - ); - } - // Invalidate lists scoped to posts and drafts - wait for completion - await queryClient.invalidateQueries({ - queryKey: queries.posts.list._def, - }); - await queryClient.invalidateQueries({ - queryKey: queries.drafts.list._def, - }); - // Refresh server-side cache (Next.js router cache) - if (refresh) { - await refresh(); - } - }, - }); + return blog.posts.update.use(); } /** Delete a post by id */ export function useDeletePost() { - const { refresh, apiBaseURL, apiBasePath } = - usePluginOverrides("blog"); - - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - - const queryClient = useQueryClient(); - const queries = createBlogQueryKeys(client); - - return useMutation<{ success: boolean }, Error, { id: string }>({ - mutationKey: [...queries.posts._def, "delete"], - mutationFn: async ({ id }: { id: string }) => { - const response = await client(`@delete/posts/:id`, { - method: "DELETE", - params: { id }, - }); - return response.data as { success: boolean }; - }, - onSuccess: async () => { - // Invalidate all post lists and detail caches - wait for completion - await queryClient.invalidateQueries({ - queryKey: queries.posts._def, - }); - await queryClient.invalidateQueries({ - queryKey: queries.drafts.list._def, - }); - // Refresh server-side cache (Next.js router cache) - if (refresh) { - await refresh(); - } - }, - }); + return blog.posts.delete.use(); } /** @@ -610,28 +319,13 @@ export function useNextPreviousPosts( createdAt: string | Date, options: UseNextPreviousPostsOptions = {}, ): UseNextPreviousPostsResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createBlogQueryKeys(client, headers); - const dateValue = typeof createdAt === "string" ? new Date(createdAt) : createdAt; - const baseQuery = queries.posts.nextPrevious(dateValue); - - const { data, isLoading, error, refetch } = useQuery< - { previous: SerializedPost | null; next: SerializedPost | null }, - Error, - { previous: SerializedPost | null; next: SerializedPost | null }, - typeof baseQuery.queryKey - >({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - enabled: (options.enabled ?? true) && !!client, - }); + + const { data, isLoading, error, refetch } = blog.posts.nextPrevious.use( + [dateValue], + { enabled: options.enabled ?? true }, + ); return { previousPost: data?.previous ?? null, @@ -675,29 +369,10 @@ export interface UseRecentPostsResult { export function useRecentPosts( options: UseRecentPostsOptions = {}, ): UseRecentPostsResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("blog"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createBlogQueryKeys(client, headers); - - const baseQuery = queries.posts.recent({ - limit: options.limit ?? 5, - excludeSlug: options.excludeSlug, - }); - - const { data, isLoading, error, refetch } = useQuery< - SerializedPost[], - Error, - SerializedPost[], - typeof baseQuery.queryKey - >({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - enabled: (options.enabled ?? true) && !!client, - }); + const { data, isLoading, error, refetch } = blog.posts.recent.use( + [{ limit: options.limit ?? 5, excludeSlug: options.excludeSlug }], + { enabled: options.enabled ?? true }, + ); return { recentPosts: data ?? [], diff --git a/packages/stack/src/plugins/blog/client/hooks/blog-resource.ts b/packages/stack/src/plugins/blog/client/hooks/blog-resource.ts new file mode 100644 index 00000000..15dc6584 --- /dev/null +++ b/packages/stack/src/plugins/blog/client/hooks/blog-resource.ts @@ -0,0 +1,13 @@ +"use client"; + +import { createResource } from "@btst/stack/plugins/client/hooks"; +import { blogResources } from "../../query-keys"; + +/** + * Factory-generated blog resource hooks. Internal — the public hook surface + * (`usePosts`, `useSuspensePost`, ...) in `blog-hooks.tsx` wraps these. + */ +export const blog = createResource({ + plugin: "blog", + resources: blogResources, +}); diff --git a/packages/stack/src/plugins/blog/client/localization/blog-forms.ts b/packages/stack/src/plugins/blog/client/localization/blog-forms.ts index 46191a23..f49a7dee 100644 --- a/packages/stack/src/plugins/blog/client/localization/blog-forms.ts +++ b/packages/stack/src/plugins/blog/client/localization/blog-forms.ts @@ -26,6 +26,7 @@ export const BLOG_FORMS = { BLOG_FORMS_TOAST_CREATE_SUCCESS: "Post created successfully", BLOG_FORMS_TOAST_UPDATE_SUCCESS: "Post updated successfully", BLOG_FORMS_TOAST_DELETE_SUCCESS: "Post deleted successfully", + BLOG_FORMS_TOAST_DELETE_FAILURE: "Failed to delete post", BLOG_FORMS_LOADING_POST: "Loading post...", // Delete post diff --git a/packages/stack/src/plugins/blog/query-keys.ts b/packages/stack/src/plugins/blog/query-keys.ts index f232660e..e4b9d1b1 100644 --- a/packages/stack/src/plugins/blog/query-keys.ts +++ b/packages/stack/src/plugins/blog/query-keys.ts @@ -1,9 +1,11 @@ -import { - mergeQueryKeys, - createQueryKeys, -} from "@lukemorales/query-key-factory"; import type { BlogApiRouter } from "./api"; -import { createApiClient } from "@btst/stack/plugins/client"; +import { + createApiClient, + createResourceQueryKeys, + type ResourcesDeclaration, +} from "@btst/stack/plugins/client"; +import type { z } from "zod"; +import type { createPostSchema, updatePostSchema } from "./schemas"; import type { SerializedPost, SerializedTag } from "./types"; import { postsListDiscriminator } from "./api/query-key-defs"; @@ -14,265 +16,172 @@ interface PostsListParams { tagSlug?: string; } -// Type guard for better-call error responses -// better-call client returns Error$1 | Data -// We check if error exists and is not null/undefined to determine it's an error response -function isErrorResponse( - response: unknown, -): response is { error: unknown; data?: never } { - return ( - typeof response === "object" && - response !== null && - "error" in response && - response.error !== null && - response.error !== undefined - ); -} - -// Helper to convert error to a proper Error object with meaningful message -function toError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - // Handle object errors (likely from better-call APIError) - if (typeof error === "object" && error !== null) { - // Try to extract message from common error object structures - const errorObj = error as Record; - const message = - (typeof errorObj.message === "string" ? errorObj.message : null) || - (typeof errorObj.error === "string" ? errorObj.error : null) || - JSON.stringify(error); - - const err = new Error(message); - // Preserve other properties - Object.assign(err, error); - return err; - } - - // Fallback for primitive values - return new Error(String(error)); -} - -export function createBlogQueryKeys( - client: ReturnType>, - headers?: HeadersInit, -) { - const posts = createPostsQueries(client, headers); - const drafts = createDraftsQueries(client, headers); - const tags = createTagsQueries(client, headers); - - return mergeQueryKeys(posts, drafts, tags); -} - -function createPostsQueries( - client: ReturnType>, - headers?: HeadersInit, -) { - return createQueryKeys("posts", { - list: (params?: PostsListParams) => ({ - queryKey: [ - postsListDiscriminator({ - published: params?.published ?? true, +type PostCreateInput = z.infer; +type PostUpdateInput = z.infer; + +/** + * Blog resource declaration — the single source of truth for query keys, + * HTTP mappings and mutations. Feeds both `createBlogQueryKeys` (SSR + * loaders) and `createResource` (client hooks, see `client/hooks`). + * + * Key shapes intentionally match `BLOG_QUERY_KEYS` in + * `api/query-key-defs.ts` so SSG `prefetchForRoute` hydration keeps working. + */ +export const blogResources = { + posts: { + queries: { + list: { + path: "/posts", + query: (params?: PostsListParams) => ({ + query: params?.query, limit: params?.limit ?? 10, + published: + params?.published !== undefined + ? params.published + ? "true" + : "false" + : undefined, tagSlug: params?.tagSlug, - query: params?.query, }), - ], - queryFn: async ({ pageParam }: { pageParam?: number }) => { - try { - const response = await client("/posts", { - method: "GET", - query: { - query: params?.query, - offset: pageParam ?? 0, - limit: params?.limit ?? 10, - published: - params?.published !== undefined - ? params.published - ? "true" - : "false" - : undefined, - tagSlug: params?.tagSlug, - }, - headers, - }); - // Check for errors (better-call returns Error$1 | Data) - if (isErrorResponse(response)) { - const errorResponse = response as { error: unknown }; - throw toError(errorResponse.error); - } - // Extract .items from the paginated response for infinite scroll compatibility - const dataResponse = response as { data?: { items?: unknown[] } }; - return (dataResponse.data?.items ?? - []) as unknown as SerializedPost[]; - } catch (error) { - // Re-throw errors so React Query can catch them - throw error; - } + key: (params?: PostsListParams) => [ + postsListDiscriminator({ + published: params?.published ?? true, + limit: params?.limit ?? 10, + tagSlug: params?.tagSlug, + query: params?.query, + }), + ], + select: (data: any, _params?: PostsListParams): SerializedPost[] => + data?.items ?? [], + infinite: true, + pageSize: (params?: PostsListParams) => params?.limit ?? 10, }, - }), - // Simplified detail query - detail: (slug: string) => ({ - queryKey: [slug], - queryFn: async () => { - if (!slug) return null; - - try { - const response = await client("/posts", { - method: "GET", - query: { slug, limit: 1 }, - headers, - }); - // Check for errors (better-call returns Error$1 | Data) - if (isErrorResponse(response)) { - const errorResponse = response as { error: unknown }; - throw toError(errorResponse.error); - } - // Type narrowed to Data after error check — access .items[0] - const dataResponse = response as { data?: { items?: unknown[] } }; - return (dataResponse.data?.items?.[0] ?? - null) as unknown as SerializedPost | null; - } catch (error) { - // Re-throw errors so React Query can catch them - throw error; - } + detail: { + path: "/posts", + query: (slug: string) => ({ slug, limit: 1 }), + key: (slug: string) => [slug], + select: (data: any, _slug: string): SerializedPost | null => + data?.items?.[0] ?? null, + skip: (slug: string) => !slug, }, - }), - // Next/previous posts query - nextPrevious: (date: Date | string) => ({ - queryKey: ["nextPrevious", date], - queryFn: async () => { - const dateValue = typeof date === "string" ? new Date(date) : date; - const response = await client("/posts/next-previous", { - method: "GET", - query: { - date: dateValue.toISOString(), - }, - headers, - }); - // Check for errors (better-call returns Error$1 | Data<...>) - if (isErrorResponse(response)) { - const errorResponse = response as { error: unknown }; - throw toError(errorResponse.error); - } - // Type narrowed to Data<...> after error check - const dataResponse = response as { data?: unknown }; - return dataResponse.data as { + nextPrevious: { + path: "/posts/next-previous", + query: (date: Date | string) => ({ + date: (typeof date === "string" + ? new Date(date) + : date + ).toISOString(), + }), + key: (date: Date | string) => ["nextPrevious", date], + select: ( + data: any, + _date: Date | string, + ): { previous: SerializedPost | null; next: SerializedPost | null; - }; + } => data, }, - }), - - // Recent posts query (separate from main list to avoid cache conflicts) - recent: (params?: { limit?: number; excludeSlug?: string }) => ({ - queryKey: ["recent", params], - queryFn: async () => { - try { - const response = await client("/posts", { - method: "GET", - query: { - limit: params?.limit ?? 5, - published: "true", - }, - headers, - }); - // Check for errors (better-call returns Error$1 | Data) - if (isErrorResponse(response)) { - const errorResponse = response as { error: unknown }; - throw toError(errorResponse.error); - } - // Extract .items from the paginated response - const recentResponse = response as { data?: { items?: unknown[] } }; - let posts = (recentResponse.data?.items ?? - []) as unknown as SerializedPost[]; - - // Exclude current post if specified - if (params?.excludeSlug) { - posts = posts.filter((post) => post.slug !== params.excludeSlug); - } - return posts; - } catch (error) { - // Re-throw errors so React Query can catch them - throw error; - } + // Recent posts query (separate from the main list to avoid cache conflicts) + recent: { + path: "/posts", + query: (params?: { limit?: number; excludeSlug?: string }) => ({ + limit: params?.limit ?? 5, + published: "true", + }), + key: (params?: { limit?: number; excludeSlug?: string }) => [ + "recent", + params, + ], + select: ( + data: any, + params?: { limit?: number; excludeSlug?: string }, + ): SerializedPost[] => { + const posts: SerializedPost[] = data?.items ?? []; + return params?.excludeSlug + ? posts.filter((post) => post.slug !== params.excludeSlug) + : posts; + }, }, - }), - }); -} - -function createDraftsQueries( - client: ReturnType>, - headers?: HeadersInit, -) { - return createQueryKeys("drafts", { - list: (params?: PostsListParams) => ({ - queryKey: [ - { - ...(params?.limit && { limit: params.limit }), + }, + + mutations: { + create: { + path: "@post/posts", + method: "POST" as const, + input: (vars: PostCreateInput) => ({ body: vars }), + select: (data: any) => data as SerializedPost | null, + invalidates: ["posts.list", "drafts.list"], + setData: { + query: "detail", + args: (created: SerializedPost | null) => + created?.slug ? [created.slug] : null, }, - ], - queryFn: async ({ pageParam }: { pageParam?: number }) => { - try { - const response = await client("/posts", { - method: "GET", - query: { - query: params?.query, - offset: pageParam ?? 0, - limit: params?.limit ?? 10, - published: "false", - }, - headers, - }); - // Check for errors (better-call returns Error$1 | Data) - if (isErrorResponse(response)) { - const errorResponse = response as { error: unknown }; - throw toError(errorResponse.error); - } - // Extract .items from the paginated response for infinite scroll compatibility - const draftsResponse = response as { data?: { items?: unknown[] } }; - return (draftsResponse.data?.items ?? - []) as unknown as SerializedPost[]; - } catch (error) { - // Re-throw errors so React Query can catch them - throw error; - } }, - }), - }); -} + update: { + path: "@put/posts/:id", + method: "PUT" as const, + input: (vars: { id: string; data: PostUpdateInput }) => ({ + params: { id: vars.id }, + body: vars.data, + }), + select: (data: any) => data as SerializedPost | null, + invalidates: ["posts.list", "drafts.list"], + setData: { + query: "detail", + args: (updated: SerializedPost | null) => + updated?.slug ? [updated.slug] : null, + }, + }, + delete: { + path: "@delete/posts/:id", + method: "DELETE" as const, + input: (vars: { id: string }) => ({ params: { id: vars.id } }), + select: (data: any) => data as { success: boolean }, + invalidates: ["posts", "drafts.list"], + }, + }, + }, + + drafts: { + queries: { + list: { + path: "/posts", + query: (params?: PostsListParams) => ({ + query: params?.query, + limit: params?.limit ?? 10, + published: "false", + }), + key: (params?: PostsListParams) => [ + { + ...(params?.limit && { limit: params.limit }), + }, + ], + select: (data: any, _params?: PostsListParams): SerializedPost[] => + data?.items ?? [], + infinite: true, + pageSize: (params?: PostsListParams) => params?.limit ?? 10, + }, + }, + }, + + tags: { + queries: { + list: { + path: "/tags", + key: () => ["tags"], + // The API returns serialized tags (dates as strings) + select: (data: any): SerializedTag[] => data ?? [], + }, + }, + }, +} satisfies ResourcesDeclaration; -function createTagsQueries( +export function createBlogQueryKeys( client: ReturnType>, headers?: HeadersInit, ) { - return createQueryKeys("tags", { - list: () => ({ - queryKey: ["tags"], - queryFn: async () => { - try { - const response = await client("/tags", { - method: "GET", - headers, - }); - // Check for errors (better-call returns Error$1 | Data) - if (isErrorResponse(response)) { - const errorResponse = response as { error: unknown }; - throw toError(errorResponse.error); - } - // Type narrowed to Data after error check - // The API returns serialized tags (dates as strings) - return ((response as { data?: unknown }).data ?? - []) as unknown as SerializedTag[]; - } catch (error) { - // Re-throw errors so React Query can catch them - throw error; - } - }, - }), - }); + return createResourceQueryKeys(client, blogResources, headers); } diff --git a/packages/stack/src/plugins/client/hooks/index.tsx b/packages/stack/src/plugins/client/hooks/index.tsx new file mode 100644 index 00000000..12a50dce --- /dev/null +++ b/packages/stack/src/plugins/client/hooks/index.tsx @@ -0,0 +1,32 @@ +/** + * Client-only resource hooks (`"use client"` modules). + * + * Kept separate from `@btst/stack/plugins/client` so plugin factory entries + * (`client/plugin.tsx`) stay server-import-safe during SSG. + */ + +export { + createResource, + type CreateResourceConfig, + type Resource, + type ResourceDetailData, + type ResourceHandle, + type ResourceInfiniteQueryHooks, + type ResourceMutationHooks, + type ResourcePlainQueryHooks, + type ResourceQueryHooks, + type ResourceQueryOptions, +} from "../resource/hooks"; +export type { ResourceOverrides } from "../resource/internal"; +export { + createUseForm, + type ResourceFormConfig, + type ResourceFormResult, +} from "../resource/use-form"; +export { + createUseSelect, + type ResourceSelectConfig, + type ResourceSelectOption, + type ResourceSelectResult, +} from "../resource/use-select"; +export { useDebounce } from "../resource/use-debounce"; diff --git a/packages/stack/src/plugins/client/index.ts b/packages/stack/src/plugins/client/index.ts index 775a5bff..12be1d95 100644 --- a/packages/stack/src/plugins/client/index.ts +++ b/packages/stack/src/plugins/client/index.ts @@ -26,6 +26,36 @@ export { SSR_LOADER_ERROR_MESSAGE, } from "../utils"; +// Shared error contract + React Query config for data plugins +export { + isErrorResponse, + SHARED_QUERY_CONFIG, + toError, +} from "./resource/errors"; +export type { StackError } from "./resource/errors"; + +// Resource declaration types + server-safe query-key factory +export { + buildQueryKey, + createResourceQueryKeys, + resolvePageSize, + runResourceMutation, + runResourceQuery, +} from "./resource/queries"; +export type { + ResourceClient, + ResourceDef, + ResourceMutationDef, + ResourceMutationResult, + ResourceMutationVars, + ResourceQueryArgs, + ResourceQueryData, + ResourceQueryDef, + ResourceQueryEntry, + ResourceQueryKeys, + ResourcesDeclaration, +} from "./resource/queries"; + // Re-export Yar types needed for plugins export type { Route, RouteContext, RouteDef } from "@btst/yar"; export { diff --git a/packages/stack/src/plugins/client/resource/errors.ts b/packages/stack/src/plugins/client/resource/errors.ts new file mode 100644 index 00000000..8510b34d --- /dev/null +++ b/packages/stack/src/plugins/client/resource/errors.ts @@ -0,0 +1,160 @@ +/** + * Shared error contract and React Query config for all data plugins. + * + * Every plugin used to copy-paste `isErrorResponse` / `toError` / + * `SHARED_QUERY_CONFIG` into its own `query-keys.ts` / hooks files. These now + * live in core and are exported once from `@btst/stack/plugins/client`. + */ + +/** + * Standardized error shape thrown by resource queries and mutations. + * + * `errors` maps field names to validation message(s), preserving Zod + * field-level issues from better-call endpoint validation errors so form + * hooks can map them onto per-field error state. + */ +export interface StackError extends Error { + statusCode?: number; + errors?: Record; +} + +/** + * Shared React Query configuration for all plugin queries. + * Prevents automatic refetching to avoid hydration mismatches in SSR. + */ +export const SHARED_QUERY_CONFIG = { + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes +} as const; + +/** + * Type guard for better-call error responses. + * better-call client returns `Error$1 | Data` — we check if + * `error` exists and is not null/undefined to determine it's an error response. + */ +export function isErrorResponse( + response: unknown, +): response is { error: unknown; data?: never } { + return ( + typeof response === "object" && + response !== null && + "error" in response && + response.error !== null && + response.error !== undefined + ); +} + +/** Serialized validation issue shape included in better-call 400 responses. */ +interface SerializedIssue { + path?: Array; + message?: string; +} + +function issuePathToFieldName(issue: SerializedIssue): string { + const path = Array.isArray(issue.path) ? issue.path : []; + return path + .map((segment) => + typeof segment === "object" && segment !== null + ? String(segment.key) + : String(segment), + ) + .join("."); +} + +/** + * Converts a validation `issues` array into a field-name → message(s) map. + * Issues without a path are skipped — they only contribute to the top-level + * error message. + */ +function issuesToFieldErrors( + issues: unknown, +): Record | undefined { + if (!Array.isArray(issues) || issues.length === 0) return undefined; + + const fieldErrors: Record = {}; + for (const issue of issues as SerializedIssue[]) { + if (typeof issue !== "object" || issue === null) continue; + const field = issuePathToFieldName(issue); + if (!field || typeof issue.message !== "string") continue; + (fieldErrors[field] ??= []).push(issue.message); + } + + const entries = Object.entries(fieldErrors); + if (entries.length === 0) return undefined; + + return Object.fromEntries( + entries.map(([field, messages]) => [ + field, + messages.length === 1 ? messages[0]! : messages, + ]), + ); +} + +function isFieldErrorRecord( + value: unknown, +): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + return Object.values(value).every( + (v) => + typeof v === "string" || + (Array.isArray(v) && v.every((m) => typeof m === "string")), + ); +} + +/** + * Converts an unknown error (typically a better-call / better-fetch error + * response body) into a proper `StackError` with a meaningful message. + * + * Normalizes to `{ message, statusCode?, errors? }` where `errors` maps + * field names to validation messages (preserved from Zod issues in + * better-call endpoint validation error responses). + */ +export function toError(error: unknown): StackError { + if (error instanceof Error) { + return error as StackError; + } + + // Handle object errors (likely from better-call APIError response bodies) + if (typeof error === "object" && error !== null) { + const errorObj = error as Record; + const message = + (typeof errorObj.message === "string" ? errorObj.message : null) || + (typeof errorObj.error === "string" ? errorObj.error : null) || + JSON.stringify(error); + + const err = new Error(message) as StackError; + // Preserve other properties (status, code, etc.) + Object.assign(err, error); + + const statusCode = + typeof errorObj.statusCode === "number" + ? errorObj.statusCode + : typeof errorObj.status === "number" + ? errorObj.status + : undefined; + if (statusCode !== undefined) { + err.statusCode = statusCode; + } + + const fieldErrors = isFieldErrorRecord(errorObj.errors) + ? errorObj.errors + : issuesToFieldErrors(errorObj.issues); + if (fieldErrors) { + err.errors = fieldErrors; + } else { + // Object.assign may have copied a non-conforming `errors` property + err.errors = undefined; + } + + return err; + } + + // Fallback for primitive values + return new Error(String(error)); +} diff --git a/packages/stack/src/plugins/client/resource/hooks.tsx b/packages/stack/src/plugins/client/resource/hooks.tsx new file mode 100644 index 00000000..ee2f7977 --- /dev/null +++ b/packages/stack/src/plugins/client/resource/hooks.tsx @@ -0,0 +1,282 @@ +"use client"; + +/** + * `createResource` — generates the repetitive React Query plumbing for a + * plugin's resources from a single declaration: plain queries, suspense + * queries, infinite queries, mutations with invalidation, plus `useForm` + * and `useSelect` per resource. + * + * The same declaration feeds `createResourceQueryKeys` (see `./queries`), + * so SSR loaders and SSG `prefetchForRoute` keys stay in sync with hooks. + * + * @example + * ```ts + * const blog = createResource({ plugin: "blog", resources: blogResources }); + * + * export const usePost = (slug?: string) => + * blog.posts.detail.use([slug ?? ""], { enabled: !!slug }); + * export const useCreatePost = () => blog.posts.create.use(); + * ``` + */ + +import { + useInfiniteQuery, + useQuery, + useSuspenseInfiniteQuery, + useSuspenseQuery, + type InfiniteData, + type UseInfiniteQueryResult, + type UseMutationResult, + type UseQueryResult, + type UseSuspenseInfiniteQueryResult, + type UseSuspenseQueryResult, +} from "@tanstack/react-query"; +import { SHARED_QUERY_CONFIG } from "./errors"; +import { + buildQueryKey, + resolvePageSize, + runResourceQuery, + type ResourceDef, + type ResourceMutationDef, + type ResourceMutationResult, + type ResourceMutationVars, + type ResourceQueryArgs, + type ResourceQueryData, + type ResourceQueryDef, + type ResourcesDeclaration, +} from "./queries"; +import { useResourceContext, useResourceMutationForDef } from "./internal"; +import { + createUseForm, + type ResourceFormConfig, + type ResourceFormResult, +} from "./use-form"; +import { + createUseSelect, + type ResourceSelectConfig, + type ResourceSelectResult, +} from "./use-select"; + +/** Per-call options for generated query hooks. */ +export interface ResourceQueryOptions { + enabled?: boolean; +} + +/** Hooks generated for a non-infinite query declaration. */ +export interface ResourcePlainQueryHooks< + TArgs extends readonly unknown[], + TData, +> { + use( + args?: TArgs, + options?: ResourceQueryOptions, + ): UseQueryResult; + /** Suspense variant — re-throws refetch errors for Error Boundaries */ + useSuspense(args?: TArgs): UseSuspenseQueryResult; +} + +/** Hooks generated for an infinite query declaration. */ +export interface ResourceInfiniteQueryHooks< + TArgs extends readonly unknown[], + TData, +> { + useInfinite( + args?: TArgs, + options?: ResourceQueryOptions, + ): UseInfiniteQueryResult, Error>; + /** Suspense variant — re-throws refetch errors for Error Boundaries */ + useSuspenseInfinite( + args?: TArgs, + ): UseSuspenseInfiniteQueryResult, Error>; +} + +/** Dispatches to the plain or infinite hook set based on the declaration. */ +export type ResourceQueryHooks = TDef extends { infinite: true } + ? ResourceInfiniteQueryHooks, ResourceQueryData> + : ResourcePlainQueryHooks, ResourceQueryData>; + +/** Hook generated for a mutation declaration. */ +export interface ResourceMutationHooks { + use(): UseMutationResult< + ResourceMutationResult, + Error, + ResourceMutationVars + >; +} + +/** The detail-query record type of a resource (used by `useForm` defaults). */ +export type ResourceDetailData = + TResource["queries"] extends { detail: infer TDetail } + ? ResourceQueryData + : unknown; + +/** The generated handle for a single resource. */ +export type ResourceHandle = { + [Q in keyof TResource["queries"]]: ResourceQueryHooks< + TResource["queries"][Q] + >; +} & { + [M in keyof NonNullable]: ResourceMutationHooks< + NonNullable[M] + >; +} & { + useForm>( + config: ResourceFormConfig, + ): ResourceFormResult; + useSelect( + config: ResourceSelectConfig, + ): ResourceSelectResult; +}; + +/** The generated resource handles, keyed by resource name. */ +export type Resource = { + [R in keyof TResources]: ResourceHandle; +}; + +export interface CreateResourceConfig { + /** Plugin name used to resolve overrides (`usePluginOverrides(plugin)`) */ + plugin: string; + resources: TResources; +} + +function createQueryHooks( + plugin: string, + resourceName: string, + queryName: string, + def: ResourceQueryDef, +) { + const useQueryConfig = (args: readonly unknown[]) => { + const context = useResourceContext(plugin); + return { + queryKey: buildQueryKey(resourceName, queryName, def, args), + queryFn: (queryContext?: { pageParam?: unknown }) => + runResourceQuery( + context.client, + def, + args, + queryContext?.pageParam, + context.headers, + ), + ...SHARED_QUERY_CONFIG, + }; + }; + + const infiniteExtras = (args: readonly unknown[]) => { + const pageSize = resolvePageSize(def, args); + return { + initialPageParam: 0, + getNextPageParam: (lastPage: unknown, allPages: unknown[]) => { + const items = (lastPage as unknown[]) ?? []; + if (items.length < pageSize) return undefined; + return allPages.length * pageSize; + }, + }; + }; + + return { + use(args: readonly unknown[] = [], options?: ResourceQueryOptions) { + return useQuery({ + ...useQueryConfig(args), + ...(options?.enabled !== undefined ? { enabled: options.enabled } : {}), + }); + }, + useSuspense(args: readonly unknown[] = []) { + const result = useSuspenseQuery(useQueryConfig(args)); + // useSuspenseQuery only throws on initial fetch — manually re-throw + // refetch errors so Error Boundaries catch them + if (result.error && !result.isFetching) { + throw result.error; + } + return result; + }, + useInfinite(args: readonly unknown[] = [], options?: ResourceQueryOptions) { + return useInfiniteQuery({ + ...useQueryConfig(args), + ...infiniteExtras(args), + ...(options?.enabled !== undefined ? { enabled: options.enabled } : {}), + }); + }, + useSuspenseInfinite(args: readonly unknown[] = []) { + const result = useSuspenseInfiniteQuery({ + ...useQueryConfig(args), + ...infiniteExtras(args), + }); + if (result.error && !result.isFetching) { + throw result.error; + } + return result; + }, + }; +} + +function createMutationHook( + plugin: string, + resourceName: string, + mutationName: string, + resource: ResourceDef, + def: ResourceMutationDef, +) { + return { + use() { + const context = useResourceContext(plugin); + return useResourceMutationForDef( + context, + resourceName, + mutationName, + resource, + def, + ); + }, + }; +} + +/** + * Generates the full hook surface for a plugin's resources. + * + * Hooks resolve `apiBaseURL` / `apiBasePath` / `headers` / `navigate` / + * `refresh` from `usePluginOverrides(plugin)` at render time, so the + * declaration can live at module scope. + */ +export function createResource( + config: CreateResourceConfig, +): Resource { + const { plugin, resources } = config; + const handles: Record = {}; + + for (const [resourceName, resource] of Object.entries(resources)) { + const handle: Record = {}; + + for (const [queryName, def] of Object.entries(resource.queries)) { + handle[queryName] = createQueryHooks( + plugin, + resourceName, + queryName, + def, + ); + } + + for (const [mutationName, def] of Object.entries( + resource.mutations ?? {}, + )) { + if (handle[mutationName]) { + throw new Error( + `Resource "${resourceName}" declares both a query and a mutation named "${mutationName}"`, + ); + } + handle[mutationName] = createMutationHook( + plugin, + resourceName, + mutationName, + resource, + def, + ); + } + + handle.useForm = createUseForm(plugin, resourceName, resource); + handle.useSelect = createUseSelect(plugin, resourceName, resource); + + handles[resourceName] = handle; + } + + return handles as Resource; +} diff --git a/packages/stack/src/plugins/client/resource/internal.ts b/packages/stack/src/plugins/client/resource/internal.ts new file mode 100644 index 00000000..5b59db0f --- /dev/null +++ b/packages/stack/src/plugins/client/resource/internal.ts @@ -0,0 +1,110 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { UseMutationResult } from "@tanstack/react-query"; +import { usePluginOverrides } from "../../../context"; +import { createApiClient } from "../../utils"; +import { + buildQueryKey, + runResourceMutation, + type ResourceClient, + type ResourceDef, + type ResourceMutationDef, +} from "./queries"; + +/** + * The override fields the resource layer needs from `usePluginOverrides`. + * All plugins expose these — `apiBaseURL`/`apiBasePath` directly, the router + * fields via the top-level `router` prop merge on `StackProvider`. + */ +export interface ResourceOverrides { + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; + navigate?: (path: string) => void | Promise; + refresh?: () => void | Promise; +} + +export interface ResourceContext { + client: ResourceClient; + headers?: HeadersInit; + navigate?: (path: string) => void | Promise; + refresh?: () => void | Promise; +} + +/** Resolves the plugin overrides and builds the better-call client. */ +export function useResourceContext(plugin: string): ResourceContext { + const { apiBaseURL, apiBasePath, headers, navigate, refresh } = + usePluginOverrides(plugin); + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + }); + return { client, headers, navigate, refresh }; +} + +/** + * Splits an `invalidates` target (`"posts"` or `"posts.list"`) into a + * query-key prefix. + */ +function invalidateTargetToKey(target: string): readonly unknown[] { + const dotIndex = target.indexOf("."); + if (dotIndex === -1) return [target]; + return [target.slice(0, dotIndex), target.slice(dotIndex + 1)]; +} + +/** + * Shared mutation hook used by both the generated mutation hooks and + * `useForm`. When `def` is undefined (e.g. `useForm` on a resource without a + * declared `create` mutation), the mutation rejects with a descriptive error. + */ +export function useResourceMutationForDef( + context: ResourceContext, + resourceName: string, + mutationName: string, + resource: ResourceDef, + def: ResourceMutationDef | undefined, +): UseMutationResult { + const queryClient = useQueryClient(); + const { client, refresh } = context; + + return useMutation({ + mutationKey: [resourceName, mutationName], + mutationFn: (vars: unknown) => { + if (!def) { + throw new Error( + `Resource "${resourceName}" has no "${mutationName}" mutation declared`, + ); + } + return runResourceMutation(client, def, vars); + }, + onSuccess: async (result) => { + if (!def) return; + + // Seed a query cache entry (e.g. detail) from the mutation result + if (def.setData) { + const keyArgs = def.setData.args(result); + const targetName = def.setData.query ?? "detail"; + const targetDef = resource.queries[targetName]; + if (keyArgs && targetDef) { + queryClient.setQueryData( + buildQueryKey(resourceName, targetName, targetDef, keyArgs), + result, + ); + } + } + + // Invalidate declared key prefixes — awaited, in declaration order + for (const target of def.invalidates ?? []) { + await queryClient.invalidateQueries({ + queryKey: invalidateTargetToKey(target), + }); + } + + // Refresh server-side cache (e.g. Next.js router cache) + if (refresh) { + await refresh(); + } + }, + }); +} diff --git a/packages/stack/src/plugins/client/resource/queries.ts b/packages/stack/src/plugins/client/resource/queries.ts new file mode 100644 index 00000000..12008e6d --- /dev/null +++ b/packages/stack/src/plugins/client/resource/queries.ts @@ -0,0 +1,279 @@ +/** + * Server-safe resource declaration types and query-key/queryFn builder. + * + * A plugin declares its resources once (paths, query mappings, unwrapping, + * key discriminators) and gets: + * + * - `createResourceQueryKeys(client, resources, headers?)` — a query-key + * factory usable from SSR loaders and `query-keys.ts` (no React), with the + * same `_def` / `queryKey` shapes as `@lukemorales/query-key-factory`, so + * existing SSG `prefetchForRoute` / `query-key-defs.ts` keys keep matching. + * - `createResource(...)` (see `./hooks`) — generated React Query hooks. + */ + +import { isErrorResponse, toError } from "./errors"; + +/** + * Minimal better-call client shape the resource layer needs. + * Any `createApiClient()` result is assignable. + */ +export type ResourceClient = (path: any, options?: any) => Promise; + +/** + * Declaration for a single query on a resource. + * + * Query-key shape: `[resourceName, queryName, ...key(...args)]` where `key` + * defaults to the args themselves. Share discriminator functions with + * `api/query-key-defs.ts` to keep SSG prefetch keys in sync. + */ +export interface ResourceQueryDef< + TArgs extends readonly unknown[] = readonly any[], + TData = unknown, +> { + /** better-call endpoint path, e.g. `"/posts"` */ + path: string; + /** Maps hook args to the HTTP query object */ + query?: (...args: TArgs) => Record | undefined; + /** + * Maps hook args to the queryKey discriminator cells appended after + * `[resourceName, queryName]`. Defaults to the args themselves. + */ + key?: (...args: TArgs) => readonly unknown[]; + /** Unwraps/selects data from the raw response data */ + select?: (data: any, ...args: TArgs) => TData; + /** + * Offset-paginated infinite query. The queryFn receives `pageParam` and + * injects it into the HTTP query as `offsetParam`. `select` must return + * an array (one page of items). + */ + infinite?: boolean; + /** HTTP query param carrying the page offset (default `"offset"`) */ + offsetParam?: string; + /** + * Page size used to derive `getNextPageParam` for infinite queries + * (default 10). A function form derives it from the hook args. + */ + pageSize?: number | ((...args: TArgs) => number); + /** When true, skip fetching and resolve `null` (e.g. missing id) */ + skip?: (...args: TArgs) => boolean; +} + +/** + * Declaration for a single mutation on a resource. + */ +export interface ResourceMutationDef { + /** better-call endpoint path, e.g. `"@post/posts"` or `"@put/posts/:id"` */ + path: string; + method: "POST" | "PUT" | "PATCH" | "DELETE"; + /** + * Maps mutation variables to better-call call options. + * Defaults to `{ body: vars }`. + */ + input?: (vars: TVars) => { + body?: unknown; + params?: Record; + query?: Record; + }; + /** Unwraps the mutation result from the raw response data */ + select?: (data: any) => TResult; + /** + * Query-key prefixes invalidated (awaited, in order) after success. + * `"posts"` invalidates the whole resource, `"posts.list"` one query. + */ + invalidates?: readonly string[]; + /** + * Seed a query cache entry from the mutation result (e.g. the detail + * entry for a created/updated record). `args` returns the key args for + * the target query, or `null` to skip seeding. + */ + setData?: { + /** Target query name on the same resource (default `"detail"`) */ + query?: string; + args: (result: TResult) => readonly unknown[] | null; + }; +} + +/** Declaration for one resource: its queries and (optionally) mutations. */ +export interface ResourceDef { + queries: Record>; + mutations?: Record>; +} + +/** A plugin's full resource declaration, keyed by resource name. */ +export type ResourcesDeclaration = Record; + +/** Extracts the hook args tuple from a query declaration. */ +export type ResourceQueryArgs = TDef extends { + query: (...args: infer A) => any; +} + ? A + : 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 extends { + select: (data: any, ...args: any[]) => infer D; +} + ? D + : unknown; + +/** Extracts the variables type from a mutation declaration. */ +export type ResourceMutationVars = TDef extends { + input: (vars: infer V) => any; +} + ? V + : unknown; + +/** Extracts the result type from a mutation declaration. */ +export type ResourceMutationResult = TDef extends { + select: (data: any) => infer R; +} + ? R + : unknown; + +/** + * A query-key factory entry: call with args to get `{ queryKey, queryFn }`, + * read `_def` for the `[resourceName, queryName]` prefix. + */ +export interface ResourceQueryEntry< + TArgs extends readonly unknown[] = readonly any[], + TData = unknown, +> { + ( + ...args: TArgs + ): { + queryKey: readonly unknown[]; + queryFn: (context?: { pageParam?: unknown }) => Promise; + }; + _def: readonly [string, string]; +} + +/** The query-key factory produced from a resources declaration. */ +export type ResourceQueryKeys = { + [R in keyof TResources]: { + [Q in keyof TResources[R]["queries"]]: ResourceQueryEntry< + ResourceQueryArgs, + ResourceQueryData + >; + } & { _def: readonly [R] }; +}; + +/** Resolves the effective page size for an infinite query declaration. */ +export function resolvePageSize( + def: ResourceQueryDef, + args: readonly unknown[], +): number { + if (typeof def.pageSize === "function") return def.pageSize(...args); + return def.pageSize ?? 10; +} + +/** Builds the full query key for a query declaration and args. */ +export function buildQueryKey( + resourceName: string, + queryName: string, + def: ResourceQueryDef, + args: readonly unknown[], +): readonly unknown[] { + const cells = def.key ? def.key(...args) : args; + return [resourceName, queryName, ...cells]; +} + +/** + * Executes the fetch → error-check → unwrap dance for a query declaration. + */ +export async function runResourceQuery( + client: ResourceClient, + def: ResourceQueryDef, + args: readonly unknown[], + pageParam?: unknown, + headers?: HeadersInit, +): Promise { + if (def.skip?.(...args)) return null; + + const baseQuery = def.query?.(...args); + const query = def.infinite + ? { ...baseQuery, [def.offsetParam ?? "offset"]: pageParam ?? 0 } + : baseQuery; + + const response = await client(def.path, { + method: "GET", + ...(query !== undefined ? { query } : {}), + ...(headers !== undefined ? { headers } : {}), + }); + + if (isErrorResponse(response)) { + throw toError(response.error); + } + + const data = (response as { data?: unknown }).data; + return def.select ? def.select(data, ...args) : data; +} + +/** + * Executes a mutation declaration: fetch → error-check → unwrap. + */ +export async function runResourceMutation( + client: ResourceClient, + def: ResourceMutationDef, + vars: unknown, +): Promise { + const { body, params, query } = def.input + ? def.input(vars) + : { body: vars, params: undefined, query: undefined }; + + const response = await client(def.path, { + method: def.method, + ...(body !== undefined ? { body } : {}), + ...(params !== undefined ? { params } : {}), + ...(query !== undefined ? { query } : {}), + }); + + if (isErrorResponse(response)) { + throw toError(response.error); + } + + const data = (response as { data?: unknown }).data; + return def.select ? def.select(data) : data; +} + +/** + * Builds a query-key factory from a resources declaration. + * + * Compatible with the shapes `@lukemorales/query-key-factory` produces: + * `store.posts.list(params)` → `{ queryKey: ["posts", "list", ...], queryFn }`, + * `store.posts.list._def` → `["posts", "list"]`, `store.posts._def` → `["posts"]`. + * + * Server-safe (no React) — usable from SSR loaders and `query-keys.ts`. + */ +export function createResourceQueryKeys< + const TResources extends ResourcesDeclaration, +>( + client: ResourceClient, + resources: TResources, + headers?: HeadersInit, +): ResourceQueryKeys { + const store: Record = {}; + + for (const [resourceName, resource] of Object.entries(resources)) { + const resourceStore: Record = { + _def: [resourceName] as const, + }; + + for (const [queryName, def] of Object.entries(resource.queries)) { + const entry = (...args: readonly unknown[]) => ({ + queryKey: buildQueryKey(resourceName, queryName, def, args), + queryFn: (context?: { pageParam?: unknown }) => + runResourceQuery(client, def, args, context?.pageParam, headers), + }); + entry._def = [resourceName, queryName] as const; + resourceStore[queryName] = entry; + } + + store[resourceName] = resourceStore; + } + + return store as ResourceQueryKeys; +} diff --git a/packages/stack/src/plugins/client/resource/use-debounce.ts b/packages/stack/src/plugins/client/resource/use-debounce.ts new file mode 100644 index 00000000..008a4549 --- /dev/null +++ b/packages/stack/src/plugins/client/resource/use-debounce.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** Returns `value` debounced by `delay` milliseconds (default 500). */ +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/packages/stack/src/plugins/client/resource/use-form.ts b/packages/stack/src/plugins/client/resource/use-form.ts new file mode 100644 index 00000000..50011790 --- /dev/null +++ b/packages/stack/src/plugins/client/resource/use-form.ts @@ -0,0 +1,236 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useRef, useState } from "react"; +import { useNotify } from "../../../context"; +import { SHARED_QUERY_CONFIG, toError, type StackError } from "./errors"; +import { buildQueryKey, runResourceQuery, type ResourceDef } from "./queries"; +import { useResourceContext, useResourceMutationForDef } from "./internal"; + +/** Configuration for the per-resource `useForm` hook. */ +export interface ResourceFormConfig< + TValues, + TRecord = unknown, + TResult = unknown, +> { + action: "create" | "edit"; + /** Detail-query arg identifying the record to edit (e.g. a slug or id) */ + id?: unknown; + /** Query used to fetch the record for edit (default `"detail"`) */ + detailQuery?: string; + /** Mutation used on create submit (default `"create"`) */ + createMutation?: string; + /** Mutation used on edit submit (default `"update"`) */ + updateMutation?: string; + /** + * Externally supplied record — skips the internal detail fetch. Useful + * when the record comes from a suspense hook higher in the tree. + */ + record?: TRecord | null; + /** Default form values, or a function deriving them from the record */ + defaults?: TValues | ((record: TRecord | null) => TValues); + /** Maps form values to create-mutation variables (default: identity) */ + toCreateVars?: (values: TValues) => unknown; + /** Maps form values to update-mutation variables (default: identity) */ + toUpdateVars?: (values: TValues, record: TRecord | null) => unknown; + /** Success notification, sent through the `notify` provider */ + successMessage?: + | string + | ((result: TResult, action: "create" | "edit") => string); + /** + * Error notification for non-field errors. Field-level validation errors + * land on `fieldErrors` instead of producing a notification. + */ + errorMessage?: string | ((error: StackError) => string); + /** + * Path to navigate to after success (via the router adapter's + * `navigate`). A function form derives it from the result; return a + * falsy value to skip navigation. + */ + redirect?: + | string + | (( + result: TResult, + action: "create" | "edit", + ) => string | false | null | undefined); + /** Called after a successful submit (before redirect) */ + onSuccess?: (result: TResult) => void | Promise; +} + +/** Result of the per-resource `useForm` hook. */ +export interface ResourceFormResult< + TValues, + TRecord = unknown, + TResult = unknown, +> { + action: "create" | "edit"; + /** The record being edited (null while loading or when creating) */ + record: TRecord | null; + isLoadingRecord: boolean; + recordError: Error | null; + /** Resolved default values (stable while `record` is unchanged) */ + defaultValues: TValues | undefined; + /** Runs the right mutation; resolves the result, or undefined on error */ + submit: (values: TValues) => Promise; + isSubmitting: boolean; + /** Last submit error (normalized), or null */ + error: StackError | null; + /** Field name → validation message(s) from the last submit error */ + fieldErrors: Record; + clearErrors: () => void; +} + +/** + * Builds the per-resource `useForm` hook: bundles the create/edit lifecycle — + * fetch record for edit, defaults, submit the right mutation, invalidate, + * notify, redirect, and per-field error state from `StackError.errors`. + */ +export function createUseForm( + plugin: string, + resourceName: string, + resource: ResourceDef, +) { + return function useForm( + config: ResourceFormConfig, + ): ResourceFormResult { + const context = useResourceContext(plugin); + const notify = useNotify(); + const { action } = config; + + // --- record fetch (edit) --------------------------------------------- + const detailName = config.detailQuery ?? "detail"; + const detailDef = resource.queries[detailName]; + const hasExternalRecord = config.record !== undefined; + const detailArgs = config.id !== undefined ? [config.id] : []; + const detailEnabled = + action === "edit" && + !hasExternalRecord && + config.id !== undefined && + !!detailDef; + + const recordQuery = useQuery({ + queryKey: detailDef + ? buildQueryKey(resourceName, detailName, detailDef, detailArgs) + : [resourceName, detailName], + queryFn: () => { + if (!detailDef) { + // Unreachable: the query is disabled when detailDef is missing + throw new Error( + `Resource "${resourceName}" has no "${detailName}" query declared`, + ); + } + return runResourceQuery( + context.client, + detailDef, + detailArgs, + undefined, + context.headers, + ); + }, + ...SHARED_QUERY_CONFIG, + enabled: detailEnabled, + }); + + const record = hasExternalRecord + ? (config.record ?? null) + : ((recordQuery.data as TRecord | undefined) ?? null); + + // --- defaults ---------------------------------------------------------- + const defaultsRef = useRef(config.defaults); + defaultsRef.current = config.defaults; + const defaultValues = useMemo(() => { + const defaults = defaultsRef.current; + if (typeof defaults === "function") { + return (defaults as (record: TRecord | null) => TValues)(record); + } + return defaults; + // The defaults function itself is intentionally not a dependency — + // it is typically an inline closure; only record changes matter. + }, [record]); + + // --- mutations --------------------------------------------------------- + const createName = config.createMutation ?? "create"; + const updateName = config.updateMutation ?? "update"; + const createMutation = useResourceMutationForDef( + context, + resourceName, + createName, + resource, + resource.mutations?.[createName], + ); + const updateMutation = useResourceMutationForDef( + context, + resourceName, + updateName, + resource, + resource.mutations?.[updateName], + ); + + // --- submit ------------------------------------------------------------ + const [error, setError] = useState(null); + + const submit = async (values: TValues): Promise => { + setError(null); + try { + const isEdit = action === "edit"; + const vars = isEdit + ? config.toUpdateVars + ? config.toUpdateVars(values, record) + : values + : config.toCreateVars + ? config.toCreateVars(values) + : values; + const mutation = isEdit ? updateMutation : createMutation; + const result = (await mutation.mutateAsync(vars)) as TResult; + + if (config.successMessage) { + notify.success( + typeof config.successMessage === "function" + ? config.successMessage(result, action) + : config.successMessage, + ); + } + if (config.onSuccess) { + await config.onSuccess(result); + } + if (config.redirect) { + const path = + typeof config.redirect === "function" + ? config.redirect(result, action) + : config.redirect; + if (path) { + await context.navigate?.(path); + } + } + return result; + } catch (e) { + const stackError = toError(e); + setError(stackError); + // Field-level validation errors land on fieldErrors, not a toast + if (!stackError.errors && config.errorMessage) { + notify.error( + typeof config.errorMessage === "function" + ? config.errorMessage(stackError) + : config.errorMessage, + ); + } + return undefined; + } + }; + + const fieldErrors = useMemo(() => error?.errors ?? {}, [error]); + + return { + action, + record, + isLoadingRecord: detailEnabled ? recordQuery.isLoading : false, + recordError: detailEnabled ? recordQuery.error : null, + defaultValues, + submit, + isSubmitting: createMutation.isPending || updateMutation.isPending, + error, + fieldErrors, + clearErrors: () => setError(null), + }; + }; +} diff --git a/packages/stack/src/plugins/client/resource/use-select.ts b/packages/stack/src/plugins/client/resource/use-select.ts new file mode 100644 index 00000000..a2173bf8 --- /dev/null +++ b/packages/stack/src/plugins/client/resource/use-select.ts @@ -0,0 +1,196 @@ +"use client"; + +import { useQueries, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { SHARED_QUERY_CONFIG } from "./errors"; +import { buildQueryKey, runResourceQuery, type ResourceDef } from "./queries"; +import { useResourceContext } from "./internal"; +import { useDebounce } from "./use-debounce"; + +/** An option produced by the per-resource `useSelect` hook. */ +export interface ResourceSelectOption { + value: string; + label: string; + /** The underlying record; undefined for values that could not be resolved */ + item?: TItem; +} + +/** Configuration for the per-resource `useSelect` hook. */ +export interface ResourceSelectConfig { + /** + * Query used to fetch options (default `"list"`). Must be a non-infinite + * query whose data is an array of records. + */ + query?: string; + /** Maps the (debounced) search text to the option-query args */ + searchArgs: (search: string) => readonly unknown[]; + getOptionValue: (item: TItem) => string; + getOptionLabel: (item: TItem) => string; + /** Currently selected value(s) — preloaded when missing from options */ + value?: string | string[]; + /** + * Query used to preload selected values missing from the options + * (default query `"detail"`). Preloading is skipped when omitted. + */ + preload?: { + query?: string; + args: (value: string) => readonly unknown[]; + }; + /** Debounce for the search text in milliseconds (default 300) */ + debounceMs?: number; + enabled?: boolean; +} + +/** Result of the per-resource `useSelect` hook. */ +export interface ResourceSelectResult { + /** Search results plus preloaded selected records */ + options: ResourceSelectOption[]; + /** Options for the current `value(s)`, resolved where possible */ + selectedOptions: ResourceSelectOption[]; + search: string; + setSearch: (search: string) => void; + /** Initial load of options or preloaded values in flight */ + isLoading: boolean; + /** Search debounce pending or a search fetch in flight */ + isSearching: boolean; + error: Error | null; +} + +/** + * Builds the per-resource `useSelect` hook: debounced server-side search, + * current-value preloading, and loading states for relation pickers. + */ +export function createUseSelect( + plugin: string, + resourceName: string, + resource: ResourceDef, +) { + return function useSelect( + config: ResourceSelectConfig, + ): ResourceSelectResult { + const context = useResourceContext(plugin); + const enabled = config.enabled ?? true; + + // --- search ------------------------------------------------------------ + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, config.debounceMs ?? 300); + + const listName = config.query ?? "list"; + const listDef = resource.queries[listName]; + if (!listDef) { + throw new Error( + `Resource "${resourceName}" has no "${listName}" query declared`, + ); + } + if (listDef.infinite) { + throw new Error( + `useSelect requires a non-infinite query, but "${resourceName}.${listName}" is declared infinite`, + ); + } + + const listArgs = config.searchArgs(debouncedSearch); + const listQuery = useQuery({ + queryKey: buildQueryKey(resourceName, listName, listDef, listArgs), + queryFn: () => + runResourceQuery( + context.client, + listDef, + listArgs, + undefined, + context.headers, + ), + ...SHARED_QUERY_CONFIG, + enabled, + }); + + const items = (listQuery.data as TItem[] | null | undefined) ?? []; + + // --- current-value preloading ------------------------------------------- + const values = + config.value === undefined + ? [] + : Array.isArray(config.value) + ? config.value + : [config.value]; + + const { getOptionValue, getOptionLabel } = config; + const fetchedValues = new Set(items.map((item) => getOptionValue(item))); + const preloadName = config.preload?.query ?? "detail"; + const preloadDef = config.preload + ? resource.queries[preloadName] + : undefined; + // Wait for the initial options load before preloading, so values that + // are part of the regular options don't trigger a redundant fetch. + const missingValues = + preloadDef && enabled && !listQuery.isLoading + ? values.filter((value) => !fetchedValues.has(value)) + : []; + + const preloadQueries = useQueries({ + queries: missingValues.map((value) => { + const args = ( + config.preload as NonNullable + ).args(value); + return { + queryKey: buildQueryKey( + resourceName, + preloadName, + preloadDef as NonNullable, + args, + ), + queryFn: () => + runResourceQuery( + context.client, + preloadDef as NonNullable, + args, + undefined, + context.headers, + ), + ...SHARED_QUERY_CONFIG, + }; + }), + }); + + const preloadedItems = preloadQueries + .map((query) => query.data as TItem | null | undefined) + .filter((item): item is TItem => item !== null && item !== undefined); + + // --- options ------------------------------------------------------------- + const toOption = (item: TItem): ResourceSelectOption => ({ + value: getOptionValue(item), + label: getOptionLabel(item), + item, + }); + + const options: ResourceSelectOption[] = items.map(toOption); + const seen = new Set(options.map((option) => option.value)); + for (const item of preloadedItems) { + const option = toOption(item); + if (!seen.has(option.value)) { + seen.add(option.value); + options.push(option); + } + } + + const selectedOptions = values.map( + (value) => + options.find((option) => option.value === value) ?? { + value, + label: value, + }, + ); + + const isDebouncing = search !== debouncedSearch; + + return { + options, + selectedOptions, + search, + setSearch, + isLoading: + listQuery.isLoading || preloadQueries.some((query) => query.isLoading), + isSearching: enabled && (isDebouncing || listQuery.isFetching), + error: listQuery.error, + }; + }; +} From 55411533ef091884192b2d26124829d1bc3310e1 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:00:20 +0000 Subject: [PATCH 2/3] fix: expose usePostForm publicly so ejected registry components compile Co-authored-by: Cursor --- packages/stack/registry/btst-blog.json | 4 ++-- .../client/components/forms/post-forms.tsx | 17 +++++++---------- .../plugins/blog/client/hooks/blog-hooks.tsx | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 9da40478..21528747 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -82,7 +82,7 @@ { "path": "btst/blog/client/components/forms/post-forms.tsx", "type": "registry:component", - "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseCreatePost,\n\tuseSuspensePost,\n\tuseUpdatePost,\n\tuseDeletePost,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useMemo, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_TAGS_LABEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\tconst {\n\t\tmutateAsync: createPost,\n\t\tisPending: isCreatingPost,\n\t\terror: createPostError,\n\t} = useCreatePost();\n\n\ttype AddPostFormValues = z.input;\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\t// Auto-generate slug from title if not provided\n\t\tconst slug = data.slug || slugify(data.title);\n\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst createdPost = await createPost({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\tslug,\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_CREATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({ published: createdPost?.published ?? false });\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\n\tconst initialData = useMemo(() => {\n\t\tif (!post) return {};\n\t\treturn {\n\t\t\ttitle: post.title,\n\t\t\tcontent: post.content,\n\t\t\texcerpt: post.excerpt,\n\t\t\tslug: post.slug,\n\t\t\tpublished: post.published,\n\t\t\timage: post.image || \"\",\n\t\t\ttags: post.tags.map((tag) => ({\n\t\t\t\tid: tag.id,\n\t\t\t\tname: tag.name,\n\t\t\t\tslug: tag.slug,\n\t\t\t})),\n\t\t};\n\t}, [post]);\n\n\tconst schema = CustomPostUpdateSchema;\n\n\tconst {\n\t\tmutateAsync: updatePost,\n\t\tisPending: isUpdatingPost,\n\t\terror: updatePostError,\n\t} = useUpdatePost();\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\ttype EditPostFormValues = z.input;\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst updatedPost = await updatePost({\n\t\t\tid: post!.id,\n\t\t\tdata: {\n\t\t\t\tid: post!.id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !post?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: post?.publishedAt\n\t\t\t\t\t\t\t? new Date(post.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({\n\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\tpublished: updatedPost?.published ?? false,\n\t\t});\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\tawait deletePost({ id: post.id });\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: initialData as z.input,\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", + "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseSuspensePost,\n\tuseDeletePost,\n\tusePostForm,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype FieldValues,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { useNotify, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\n/**\n * Applies server-side field validation errors (from `StackError.errors`)\n * onto react-hook-form field state.\n */\nfunction useServerFieldErrors(\n\tform: UseFormReturn,\n\tfieldErrors: Record,\n) {\n\tuseEffect(() => {\n\t\tfor (const [field, message] of Object.entries(fieldErrors)) {\n\t\t\tform.setError(field as FieldPath, {\n\t\t\t\ttype: \"server\",\n\t\t\t\tmessage: Array.isArray(message) ? message.join(\", \") : message,\n\t\t\t});\n\t\t}\n\t}, [fieldErrors, form]);\n}\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_TAGS_LABEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\ttype AddPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"create\",\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_CREATE_SUCCESS,\n\t\ttoCreateVars: (data) => ({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t// Auto-generate slug from title if not provided\n\t\t\tslug: data.slug || slugify(data.title),\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t}),\n\t\tonSuccess: (createdPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({ published: createdPost?.published ?? false });\n\t\t},\n\t});\n\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\tconst notify = useNotify();\n\n\tconst schema = CustomPostUpdateSchema;\n\n\ttype EditPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"edit\",\n\t\t// Record comes from the suspense hook above — skips useForm's own fetch\n\t\trecord: post,\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS,\n\t\tdefaults: (record) =>\n\t\t\t(record\n\t\t\t\t? {\n\t\t\t\t\t\ttitle: record.title,\n\t\t\t\t\t\tcontent: record.content,\n\t\t\t\t\t\texcerpt: record.excerpt,\n\t\t\t\t\t\tslug: record.slug,\n\t\t\t\t\t\tpublished: record.published,\n\t\t\t\t\t\timage: record.image || \"\",\n\t\t\t\t\t\ttags: record.tags.map((tag) => ({\n\t\t\t\t\t\t\tid: tag.id,\n\t\t\t\t\t\t\tname: tag.name,\n\t\t\t\t\t\t\tslug: tag.slug,\n\t\t\t\t\t\t})),\n\t\t\t\t\t}\n\t\t\t\t: {}) as EditPostFormValues,\n\t\ttoUpdateVars: (data, record) => ({\n\t\t\tid: (record as SerializedPost).id,\n\t\t\tdata: {\n\t\t\t\tid: (record as SerializedPost).id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !record?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: record?.publishedAt\n\t\t\t\t\t\t\t? new Date(record.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t}),\n\t\tonSuccess: (updatedPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({\n\t\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\t\tpublished: updatedPost?.published ?? false,\n\t\t\t});\n\t\t},\n\t});\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\ttry {\n\t\t\tawait deletePost({ id: post.id });\n\t\t} catch (error) {\n\t\t\tnotify.error(\n\t\t\t\terror instanceof Error\n\t\t\t\t\t? error.message\n\t\t\t\t\t: localization.BLOG_FORMS_TOAST_DELETE_FAILURE,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: resourceForm.defaultValues as z.input,\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", "target": "src/components/btst/blog/client/components/forms/post-forms.tsx" }, { @@ -334,7 +334,7 @@ { "path": "btst/blog/client/localization/blog-forms.ts", "type": "registry:lib", - "content": "export const BLOG_FORMS = {\n\tBLOG_FORMS_TITLE_LABEL: \"Title\",\n\tBLOG_FORMS_REQUIRED_ASTERISK: \" *\",\n\tBLOG_FORMS_TITLE_PLACEHOLDER: \"Enter your post title...\",\n\n\tBLOG_FORMS_SLUG_LABEL: \"Slug\",\n\tBLOG_FORMS_SLUG_PLACEHOLDER: \"url-friendly-slug\",\n\n\tBLOG_FORMS_EXCERPT_LABEL: \"Excerpt\",\n\tBLOG_FORMS_EXCERPT_PLACEHOLDER: \"Brief summary of your post...\",\n\n\tBLOG_FORMS_TAGS_LABEL: \"Tags\",\n\tBLOG_FORMS_TAGS_PLACEHOLDER: \"Enter your post tags...\",\n\n\tBLOG_FORMS_CONTENT_LABEL: \"Content\",\n\n\tBLOG_FORMS_PUBLISHED_LABEL: \"Published\",\n\tBLOG_FORMS_PUBLISHED_DESCRIPTION: \"Toggle to publish immediately\",\n\n\tBLOG_FORMS_SUBMIT_CREATE_IDLE: \"Create Post\",\n\tBLOG_FORMS_SUBMIT_CREATE_PENDING: \"Creating...\",\n\tBLOG_FORMS_SUBMIT_UPDATE_IDLE: \"Update Post\",\n\tBLOG_FORMS_SUBMIT_UPDATE_PENDING: \"Updating...\",\n\tBLOG_FORMS_CANCEL_BUTTON: \"Cancel\",\n\n\tBLOG_FORMS_TOAST_CREATE_SUCCESS: \"Post created successfully\",\n\tBLOG_FORMS_TOAST_UPDATE_SUCCESS: \"Post updated successfully\",\n\tBLOG_FORMS_TOAST_DELETE_SUCCESS: \"Post deleted successfully\",\n\tBLOG_FORMS_LOADING_POST: \"Loading post...\",\n\n\t// Delete post\n\tBLOG_FORMS_DELETE_BUTTON: \"Delete Post\",\n\tBLOG_FORMS_DELETE_DIALOG_TITLE: \"Delete Post\",\n\tBLOG_FORMS_DELETE_DIALOG_DESCRIPTION:\n\t\t\"Are you sure you want to delete this post? This action cannot be undone.\",\n\tBLOG_FORMS_DELETE_DIALOG_CANCEL: \"Cancel\",\n\tBLOG_FORMS_DELETE_DIALOG_CONFIRM: \"Delete\",\n\tBLOG_FORMS_DELETE_PENDING: \"Deleting...\",\n\n\t// Markdown editor\n\tBLOG_FORMS_EDITOR_PLACEHOLDER: \"Write something...\",\n\n\t// Featured image field\n\tBLOG_FORMS_FEATURED_IMAGE_LABEL: \"Image\",\n\tBLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK: \" *\",\n\tBLOG_FORMS_FEATURED_IMAGE_INPUT_PLACEHOLDER: \"Image URL or upload below...\",\n\tBLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON: \"Upload\",\n\tBLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON: \"Uploading...\",\n\tBLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT: \"Uploading image...\",\n\tBLOG_FORMS_FEATURED_IMAGE_PREVIEW_ALT: \"Featured image preview\",\n\tBLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE: \"Please select an image file\",\n\tBLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE: \"Image size must be less than 4MB\",\n\tBLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS: \"Image uploaded successfully\",\n\tBLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE: \"Failed to upload image\",\n};\n", + "content": "export const BLOG_FORMS = {\n\tBLOG_FORMS_TITLE_LABEL: \"Title\",\n\tBLOG_FORMS_REQUIRED_ASTERISK: \" *\",\n\tBLOG_FORMS_TITLE_PLACEHOLDER: \"Enter your post title...\",\n\n\tBLOG_FORMS_SLUG_LABEL: \"Slug\",\n\tBLOG_FORMS_SLUG_PLACEHOLDER: \"url-friendly-slug\",\n\n\tBLOG_FORMS_EXCERPT_LABEL: \"Excerpt\",\n\tBLOG_FORMS_EXCERPT_PLACEHOLDER: \"Brief summary of your post...\",\n\n\tBLOG_FORMS_TAGS_LABEL: \"Tags\",\n\tBLOG_FORMS_TAGS_PLACEHOLDER: \"Enter your post tags...\",\n\n\tBLOG_FORMS_CONTENT_LABEL: \"Content\",\n\n\tBLOG_FORMS_PUBLISHED_LABEL: \"Published\",\n\tBLOG_FORMS_PUBLISHED_DESCRIPTION: \"Toggle to publish immediately\",\n\n\tBLOG_FORMS_SUBMIT_CREATE_IDLE: \"Create Post\",\n\tBLOG_FORMS_SUBMIT_CREATE_PENDING: \"Creating...\",\n\tBLOG_FORMS_SUBMIT_UPDATE_IDLE: \"Update Post\",\n\tBLOG_FORMS_SUBMIT_UPDATE_PENDING: \"Updating...\",\n\tBLOG_FORMS_CANCEL_BUTTON: \"Cancel\",\n\n\tBLOG_FORMS_TOAST_CREATE_SUCCESS: \"Post created successfully\",\n\tBLOG_FORMS_TOAST_UPDATE_SUCCESS: \"Post updated successfully\",\n\tBLOG_FORMS_TOAST_DELETE_SUCCESS: \"Post deleted successfully\",\n\tBLOG_FORMS_TOAST_DELETE_FAILURE: \"Failed to delete post\",\n\tBLOG_FORMS_LOADING_POST: \"Loading post...\",\n\n\t// Delete post\n\tBLOG_FORMS_DELETE_BUTTON: \"Delete Post\",\n\tBLOG_FORMS_DELETE_DIALOG_TITLE: \"Delete Post\",\n\tBLOG_FORMS_DELETE_DIALOG_DESCRIPTION:\n\t\t\"Are you sure you want to delete this post? This action cannot be undone.\",\n\tBLOG_FORMS_DELETE_DIALOG_CANCEL: \"Cancel\",\n\tBLOG_FORMS_DELETE_DIALOG_CONFIRM: \"Delete\",\n\tBLOG_FORMS_DELETE_PENDING: \"Deleting...\",\n\n\t// Markdown editor\n\tBLOG_FORMS_EDITOR_PLACEHOLDER: \"Write something...\",\n\n\t// Featured image field\n\tBLOG_FORMS_FEATURED_IMAGE_LABEL: \"Image\",\n\tBLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK: \" *\",\n\tBLOG_FORMS_FEATURED_IMAGE_INPUT_PLACEHOLDER: \"Image URL or upload below...\",\n\tBLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON: \"Upload\",\n\tBLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON: \"Uploading...\",\n\tBLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT: \"Uploading image...\",\n\tBLOG_FORMS_FEATURED_IMAGE_PREVIEW_ALT: \"Featured image preview\",\n\tBLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE: \"Please select an image file\",\n\tBLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE: \"Image size must be less than 4MB\",\n\tBLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS: \"Image uploaded successfully\",\n\tBLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE: \"Failed to upload image\",\n};\n", "target": "src/components/btst/blog/client/localization/blog-forms.ts" }, { diff --git a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx index 1f25a55a..4d53cce6 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx @@ -19,8 +19,11 @@ import { Input } from "@workspace/ui/components/input"; import { Switch } from "@workspace/ui/components/switch"; import { Textarea } from "@workspace/ui/components/textarea"; -import { useSuspensePost, useDeletePost } from "../../hooks/blog-hooks"; -import { blog } from "../../hooks/blog-resource"; +import { + useSuspensePost, + useDeletePost, + usePostForm, +} from "../../hooks/blog-hooks"; import { slugify } from "../../../utils"; import type { SerializedPost } from "../../../types"; import { @@ -375,10 +378,7 @@ const AddPostFormComponent = ({ type AddPostFormValues = z.input; - const resourceForm = blog.posts.useForm< - AddPostFormValues, - SerializedPost | null - >({ + const resourceForm = usePostForm({ action: "create", successMessage: localization.BLOG_FORMS_TOAST_CREATE_SUCCESS, toCreateVars: (data) => ({ @@ -493,10 +493,7 @@ const EditPostFormComponent = ({ type EditPostFormValues = z.input; - const resourceForm = blog.posts.useForm< - EditPostFormValues, - SerializedPost | null - >({ + const resourceForm = usePostForm({ action: "edit", // Record comes from the suspense hook above — skips useForm's own fetch record: post, diff --git a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx index ceb2f5ae..0ed3d344 100644 --- a/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx +++ b/packages/stack/src/plugins/blog/client/hooks/blog-hooks.tsx @@ -2,6 +2,10 @@ import { useEffect, useRef } from "react"; import { z } from "zod"; +import type { + ResourceFormConfig, + ResourceFormResult, +} from "@btst/stack/plugins/client/hooks"; import type { SerializedPost, SerializedTag } from "../../types"; import type { createPostSchema, updatePostSchema } from "../../schemas"; import { useDebounce } from "./use-debounce"; @@ -216,6 +220,21 @@ export function useSuspenseTags(): { }; } +/** + * Form lifecycle hook for creating/editing posts, built on the core + * resource `useForm`: submits the right mutation, awaits invalidation, + * notifies, redirects, and maps server validation issues to `fieldErrors`. + */ +export function usePostForm( + config: ResourceFormConfig< + TValues, + SerializedPost | null, + SerializedPost | null + >, +): ResourceFormResult { + return blog.posts.useForm(config); +} + /** Create a new post */ export function useCreatePost() { return blog.posts.create.use(); From 1d09b921e89068b2069a18e8c092ba2fd243dc01 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:16:07 +0000 Subject: [PATCH 3/3] fix: clear stale server field errors on resubmit in post forms Co-authored-by: Cursor --- packages/stack/registry/btst-blog.json | 2 +- .../client/components/forms/post-forms.tsx | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 21528747..28379d4f 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -82,7 +82,7 @@ { "path": "btst/blog/client/components/forms/post-forms.tsx", "type": "registry:component", - "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseSuspensePost,\n\tuseDeletePost,\n\tusePostForm,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype FieldValues,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { useNotify, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\n/**\n * Applies server-side field validation errors (from `StackError.errors`)\n * onto react-hook-form field state.\n */\nfunction useServerFieldErrors(\n\tform: UseFormReturn,\n\tfieldErrors: Record,\n) {\n\tuseEffect(() => {\n\t\tfor (const [field, message] of Object.entries(fieldErrors)) {\n\t\t\tform.setError(field as FieldPath, {\n\t\t\t\ttype: \"server\",\n\t\t\t\tmessage: Array.isArray(message) ? message.join(\", \") : message,\n\t\t\t});\n\t\t}\n\t}, [fieldErrors, form]);\n}\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_TAGS_LABEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\ttype AddPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"create\",\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_CREATE_SUCCESS,\n\t\ttoCreateVars: (data) => ({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t// Auto-generate slug from title if not provided\n\t\t\tslug: data.slug || slugify(data.title),\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t}),\n\t\tonSuccess: (createdPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({ published: createdPost?.published ?? false });\n\t\t},\n\t});\n\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\tconst notify = useNotify();\n\n\tconst schema = CustomPostUpdateSchema;\n\n\ttype EditPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"edit\",\n\t\t// Record comes from the suspense hook above — skips useForm's own fetch\n\t\trecord: post,\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS,\n\t\tdefaults: (record) =>\n\t\t\t(record\n\t\t\t\t? {\n\t\t\t\t\t\ttitle: record.title,\n\t\t\t\t\t\tcontent: record.content,\n\t\t\t\t\t\texcerpt: record.excerpt,\n\t\t\t\t\t\tslug: record.slug,\n\t\t\t\t\t\tpublished: record.published,\n\t\t\t\t\t\timage: record.image || \"\",\n\t\t\t\t\t\ttags: record.tags.map((tag) => ({\n\t\t\t\t\t\t\tid: tag.id,\n\t\t\t\t\t\t\tname: tag.name,\n\t\t\t\t\t\t\tslug: tag.slug,\n\t\t\t\t\t\t})),\n\t\t\t\t\t}\n\t\t\t\t: {}) as EditPostFormValues,\n\t\ttoUpdateVars: (data, record) => ({\n\t\t\tid: (record as SerializedPost).id,\n\t\t\tdata: {\n\t\t\t\tid: (record as SerializedPost).id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !record?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: record?.publishedAt\n\t\t\t\t\t\t\t? new Date(record.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t}),\n\t\tonSuccess: (updatedPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({\n\t\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\t\tpublished: updatedPost?.published ?? false,\n\t\t\t});\n\t\t},\n\t});\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\ttry {\n\t\t\tawait deletePost({ id: post.id });\n\t\t} catch (error) {\n\t\t\tnotify.error(\n\t\t\t\terror instanceof Error\n\t\t\t\t\t? error.message\n\t\t\t\t\t: localization.BLOG_FORMS_TOAST_DELETE_FAILURE,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: resourceForm.defaultValues as z.input,\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", + "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseSuspensePost,\n\tuseDeletePost,\n\tusePostForm,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useRef, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype FieldValues,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { useNotify, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\n/**\n * Applies server-side field validation errors (from `StackError.errors`)\n * onto react-hook-form field state, and clears previously applied server\n * errors that are no longer present (e.g. after a resubmit fails on\n * different fields).\n */\nfunction useServerFieldErrors(\n\tform: UseFormReturn,\n\tfieldErrors: Record,\n) {\n\tconst appliedFieldsRef = useRef([]);\n\n\tuseEffect(() => {\n\t\t// Clear stale server errors from fields that are no longer failing\n\t\tfor (const field of appliedFieldsRef.current) {\n\t\t\tif (field in fieldErrors) continue;\n\t\t\tconst { error } = form.getFieldState(field as FieldPath);\n\t\t\tif (error?.type === \"server\") {\n\t\t\t\tform.clearErrors(field as FieldPath);\n\t\t\t}\n\t\t}\n\t\tappliedFieldsRef.current = Object.keys(fieldErrors);\n\n\t\tfor (const [field, message] of Object.entries(fieldErrors)) {\n\t\t\tform.setError(field as FieldPath, {\n\t\t\t\ttype: \"server\",\n\t\t\t\tmessage: Array.isArray(message) ? message.join(\", \") : message,\n\t\t\t});\n\t\t}\n\t}, [fieldErrors, form]);\n}\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_TAGS_LABEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\ttype AddPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"create\",\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_CREATE_SUCCESS,\n\t\ttoCreateVars: (data) => ({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t// Auto-generate slug from title if not provided\n\t\t\tslug: data.slug || slugify(data.title),\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t}),\n\t\tonSuccess: (createdPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({ published: createdPost?.published ?? false });\n\t\t},\n\t});\n\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\tconst notify = useNotify();\n\n\tconst schema = CustomPostUpdateSchema;\n\n\ttype EditPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"edit\",\n\t\t// Record comes from the suspense hook above — skips useForm's own fetch\n\t\trecord: post,\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS,\n\t\tdefaults: (record) =>\n\t\t\t(record\n\t\t\t\t? {\n\t\t\t\t\t\ttitle: record.title,\n\t\t\t\t\t\tcontent: record.content,\n\t\t\t\t\t\texcerpt: record.excerpt,\n\t\t\t\t\t\tslug: record.slug,\n\t\t\t\t\t\tpublished: record.published,\n\t\t\t\t\t\timage: record.image || \"\",\n\t\t\t\t\t\ttags: record.tags.map((tag) => ({\n\t\t\t\t\t\t\tid: tag.id,\n\t\t\t\t\t\t\tname: tag.name,\n\t\t\t\t\t\t\tslug: tag.slug,\n\t\t\t\t\t\t})),\n\t\t\t\t\t}\n\t\t\t\t: {}) as EditPostFormValues,\n\t\ttoUpdateVars: (data, record) => ({\n\t\t\tid: (record as SerializedPost).id,\n\t\t\tdata: {\n\t\t\t\tid: (record as SerializedPost).id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !record?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: record?.publishedAt\n\t\t\t\t\t\t\t? new Date(record.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t}),\n\t\tonSuccess: (updatedPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({\n\t\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\t\tpublished: updatedPost?.published ?? false,\n\t\t\t});\n\t\t},\n\t});\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\ttry {\n\t\t\tawait deletePost({ id: post.id });\n\t\t} catch (error) {\n\t\t\tnotify.error(\n\t\t\t\terror instanceof Error\n\t\t\t\t\t? error.message\n\t\t\t\t\t: localization.BLOG_FORMS_TOAST_DELETE_FAILURE,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: resourceForm.defaultValues as z.input,\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", "target": "src/components/btst/blog/client/components/forms/post-forms.tsx" }, { diff --git a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx index 4d53cce6..b647b90b 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx @@ -40,7 +40,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; -import { lazy, memo, Suspense, useEffect, useState } from "react"; +import { lazy, memo, Suspense, useEffect, useRef, useState } from "react"; import { type FieldPath, type FieldValues, @@ -64,13 +64,27 @@ import { TagsMultiSelect } from "./tags-multiselect"; /** * Applies server-side field validation errors (from `StackError.errors`) - * onto react-hook-form field state. + * onto react-hook-form field state, and clears previously applied server + * errors that are no longer present (e.g. after a resubmit fails on + * different fields). */ function useServerFieldErrors( form: UseFormReturn, fieldErrors: Record, ) { + const appliedFieldsRef = useRef([]); + useEffect(() => { + // Clear stale server errors from fields that are no longer failing + for (const field of appliedFieldsRef.current) { + if (field in fieldErrors) continue; + const { error } = form.getFieldState(field as FieldPath); + if (error?.type === "server") { + form.clearErrors(field as FieldPath); + } + } + appliedFieldsRef.current = Object.keys(fieldErrors); + for (const [field, message] of Object.entries(fieldErrors)) { form.setError(field as FieldPath, { type: "server",