Skip to main content

gize_admin/
lib.rs

1//! Admin UI generator for Gize (ADR-006).
2//!
3//! `gize make admin` generates a **separate** Vite + React + TypeScript SPA under `admin/`,
4//! driven by the manifest. The app is data-driven: [`resources_ts`] emits one descriptor
5//! (fields, relationships and a Zod schema) per resource from `gize.toml`, and generic
6//! components render List/Create/Edit/Delete for any of them. It talks to the backend through
7//! a Vite dev proxy (`/api` → the API), so no CORS or backend changes are needed.
8//!
9//! The UI is a token-based design system (light + dark theme) with a Django-inspired layout:
10//! an app shell, a changelist with search/filters/pagination/bulk actions, and a slide-over
11//! drawer form. Templates are Rust functions returning file contents.
12
13use gize_core::naming::table_name;
14use gize_core::{FieldType, Manifest};
15
16/// `admin/package.json` — the SPA's dependencies and scripts.
17pub fn package_json(project: &str) -> String {
18    format!(
19        r#"{{
20  "name": "{project}-admin",
21  "private": true,
22  "type": "module",
23  "scripts": {{
24    "dev": "vite",
25    "build": "tsc --noEmit && vite build",
26    "preview": "vite preview"
27  }},
28  "dependencies": {{
29    "@hookform/resolvers": "^3.9.0",
30    "@tanstack/react-query": "^5.59.0",
31    "react": "^18.3.1",
32    "react-dom": "^18.3.1",
33    "react-hook-form": "^7.53.0",
34    "zod": "^3.23.8"
35  }},
36  "devDependencies": {{
37    "@types/react": "^18.3.11",
38    "@types/react-dom": "^18.3.1",
39    "@vitejs/plugin-react": "^4.3.2",
40    "typescript": "^5.6.2",
41    "vite": "^5.4.8"
42  }}
43}}
44"#
45    )
46}
47
48/// `admin/pnpm-workspace.yaml` — lets pnpm run esbuild's install script, which pnpm 10+
49/// blocks by default. pnpm 11 reads only `allowBuilds` and fails the install on a blocked
50/// build; older pnpm 10 (10.12 checked) reads only `onlyBuiltDependencies`; pnpm 9 and 10.0
51/// refuse a workspace file without `packages`. npm and yarn ignore the file.
52pub fn pnpm_workspace() -> String {
53    "packages:\n  - \".\"\nallowBuilds:\n  esbuild: true\nonlyBuiltDependencies:\n  - esbuild\n"
54        .to_string()
55}
56
57/// `admin/vite.config.ts` — React, and a dev proxy so `/api/*` reaches the backend.
58pub fn vite_config() -> String {
59    r#"import { defineConfig } from "vite";
60import react from "@vitejs/plugin-react";
61
62// The API base is proxied so the browser talks same-origin in dev. Set VITE_API_URL to point
63// at your running Gize backend (default http://localhost:8080).
64export default defineConfig({
65  plugins: [react()],
66  server: {
67    proxy: {
68      "/api": {
69        target: process.env.VITE_API_URL || "http://localhost:8080",
70        changeOrigin: true,
71        rewrite: (p) => p.replace(/^\/api/, ""),
72      },
73    },
74  },
75});
76"#
77    .to_string()
78}
79
80/// `admin/tsconfig.json`.
81pub fn tsconfig() -> String {
82    r#"{
83  "compilerOptions": {
84    "target": "ES2020",
85    "useDefineForClassFields": true,
86    "lib": ["ES2020", "DOM", "DOM.Iterable"],
87    "module": "ESNext",
88    "skipLibCheck": true,
89    "moduleResolution": "bundler",
90    "allowImportingTsExtensions": true,
91    "resolveJsonModule": true,
92    "isolatedModules": true,
93    "noEmit": true,
94    "jsx": "react-jsx",
95    "strict": true,
96    "noUnusedLocals": false,
97    "noUnusedParameters": false,
98    "noFallthroughCasesInSwitch": true
99  },
100  "include": ["src"]
101}
102"#
103    .to_string()
104}
105
106/// `admin/index.html`. Sets the theme before first paint to avoid a flash.
107pub fn index_html() -> String {
108    r####"<!doctype html>
109<html lang="en">
110  <head>
111    <meta charset="UTF-8" />
112    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
113    <title>Admin</title>
114    <script>
115      try {
116        var t = localStorage.getItem("gize-admin-theme");
117        if (t === "dark" || t === "light") document.documentElement.setAttribute("data-theme", t);
118      } catch (e) {}
119    </script>
120  </head>
121  <body>
122    <div id="root"></div>
123    <script type="module" src="/src/main.tsx"></script>
124  </body>
125</html>
126"####
127        .to_string()
128}
129
130/// `admin/.env.example`.
131pub fn env_example() -> String {
132    "# Point at your running Gize backend (proxied under /api in dev).\nVITE_API_URL=http://localhost:8080\n"
133        .to_string()
134}
135
136/// `admin/.gitignore`.
137pub fn gitignore() -> String {
138    "node_modules\ndist\n.env\n".to_string()
139}
140
141/// `admin/src/styles.css` — the token-based design system (light + dark).
142pub fn styles_css() -> String {
143    include_str!("assets/styles.css").to_string()
144}
145
146/// `admin/src/main.tsx`.
147pub fn main_tsx() -> String {
148    r#"import React from "react";
149import { createRoot } from "react-dom/client";
150import App from "./App";
151import "./styles.css";
152
153createRoot(document.getElementById("root")!).render(
154  <React.StrictMode>
155    <App />
156  </React.StrictMode>,
157);
158"#
159    .to_string()
160}
161
162/// `admin/src/theme.ts` — light/dark theme helpers (persisted, system-aware).
163pub fn theme_ts() -> String {
164    r#"export type Theme = "light" | "dark";
165
166export function currentTheme(): Theme {
167  const stored = safeGet();
168  if (stored === "light" || stored === "dark") return stored;
169  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
170}
171
172export function setTheme(theme: Theme): void {
173  document.documentElement.setAttribute("data-theme", theme);
174  try {
175    localStorage.setItem("gize-admin-theme", theme);
176  } catch {
177    /* ignore */
178  }
179}
180
181export function toggleTheme(): Theme {
182  const next: Theme = currentTheme() === "dark" ? "light" : "dark";
183  setTheme(next);
184  return next;
185}
186
187function safeGet(): string | null {
188  try {
189    return localStorage.getItem("gize-admin-theme");
190  } catch {
191    return null;
192  }
193}
194"#
195    .to_string()
196}
197
198/// `admin/src/icons.tsx` — a small inline SVG icon set.
199pub fn icons_tsx() -> String {
200    include_str!("assets/icons.tsx").to_string()
201}
202
203/// `admin/src/toast.tsx` — a lightweight toast (event-based, no context wiring).
204pub fn toast_tsx() -> String {
205    r#"import { useEffect, useState } from "react";
206import { Icon } from "./icons";
207
208export function toast(message: string): void {
209  window.dispatchEvent(new CustomEvent("gize-toast", { detail: message }));
210}
211
212export function ToastHost() {
213  const [msg, setMsg] = useState<string | null>(null);
214  useEffect(() => {
215    let timer: number | undefined;
216    function onToast(e: Event) {
217      setMsg((e as CustomEvent<string>).detail);
218      window.clearTimeout(timer);
219      timer = window.setTimeout(() => setMsg(null), 2400);
220    }
221    window.addEventListener("gize-toast", onToast);
222    return () => {
223      window.removeEventListener("gize-toast", onToast);
224      window.clearTimeout(timer);
225    };
226  }, []);
227  return (
228    <div className={"toast" + (msg ? " show" : "")} role="status" aria-live="polite">
229      <Icon name="check" className="ok" />
230      <span>{msg}</span>
231    </div>
232  );
233}
234"#
235    .to_string()
236}
237
238/// `admin/src/api.ts` — a tiny typed fetch client that attaches the JWT and hits `/api`.
239pub fn api_ts() -> String {
240    r#"const BASE = "/api";
241
242export function getToken(): string | null {
243  return localStorage.getItem("gize_token");
244}
245
246/** A failed API call. `status` tells a refused request (401/403) apart from a broken one. */
247export class ApiError extends Error {
248  status: number;
249  constructor(status: number, message: string) {
250    super(message);
251    this.status = status;
252  }
253}
254
255/** The HTTP status of a failed call, or undefined when the request never got an answer. */
256export function statusOf(error: unknown): number | undefined {
257  return error instanceof ApiError ? error.status : undefined;
258}
259
260async function req(path: string, opts: RequestInit = {}): Promise<any> {
261  const headers: Record<string, string> = {
262    "content-type": "application/json",
263    ...(opts.headers as Record<string, string>),
264  };
265  const t = getToken();
266  if (t) headers["authorization"] = `Bearer ${t}`;
267  const res = await fetch(`${BASE}${path}`, { ...opts, headers });
268  if (!res.ok) {
269    const body = await res.text();
270    throw new ApiError(res.status, body || String(res.status));
271  }
272  if (res.status === 204) return null;
273  return res.json();
274}
275
276/** Largest page the API serves (ADR-022). */
277const PER_PAGE = 100;
278
279/** Every row of a resource: pages of PER_PAGE until the last one, so search, filters and
280 * foreign-key selects can keep working on the full list in the browser. */
281async function listAll(path: string): Promise<any[]> {
282  const rows: any[] = [];
283  for (let page = 1; ; page++) {
284    const res = await req(`/${path}?page=${page}&per_page=${PER_PAGE}`);
285    rows.push(...res.items);
286    if (res.items.length < PER_PAGE || rows.length >= res.total) return rows;
287  }
288}
289
290export const api = {
291  list: listAll,
292  get: (path: string, id: string) => req(`/${path}/${id}`),
293  create: (path: string, body: unknown) =>
294    req(`/${path}`, { method: "POST", body: JSON.stringify(body) }),
295  update: (path: string, id: string, body: unknown) =>
296    req(`/${path}/${id}`, { method: "PUT", body: JSON.stringify(body) }),
297  remove: (path: string, id: string) => req(`/${path}/${id}`, { method: "DELETE" }),
298  login: (email: string, password: string) =>
299    req(`/users/login`, { method: "POST", body: JSON.stringify({ email, password }) }),
300};
301
302/** A resource's display label: the (plural) path in words, capitalized — e.g.
303 * "posts" -> "Posts", "ticket_types" -> "Ticket types". */
304export function titleCase(s: string): string {
305  const words = s.replace(/_/g, " ");
306  return words.charAt(0).toUpperCase() + words.slice(1);
307}
308
309/** A model name as a lowercase noun for buttons and dialogs — e.g. "TicketType" -> "ticket type". */
310export function nounOf(model: string): string {
311  return model.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
312}
313
314/** A readable label for a related record (used by foreign-key selects and cells). */
315export function labelOf(row: Record<string, any>): string {
316  for (const key of ["name", "title", "email", "slug"]) {
317    if (row[key]) return String(row[key]);
318  }
319  for (const key of Object.keys(row)) {
320    const value = row[key];
321    if (typeof value === "string" && key !== "id" && !key.endsWith("_at")) return value;
322  }
323  return String(row.id ?? "");
324}
325"#
326    .to_string()
327}
328
329/// `admin/src/auth.tsx` — token storage hook and the branded login screen.
330pub fn auth_tsx() -> String {
331    include_str!("assets/auth.tsx").to_string()
332}
333
334/// `admin/src/App.tsx` — the app shell (topbar, sidebar, drawer, toasts, theme toggle).
335pub fn app_tsx() -> String {
336    include_str!("assets/App.tsx").to_string()
337}
338
339/// `admin/src/Resource.tsx` — the changelist (search, filters, pagination, bulk actions).
340pub fn resource_tsx() -> String {
341    include_str!("assets/Resource.tsx").to_string()
342}
343
344/// `admin/src/ResourceForm.tsx` — the drawer form (fieldsets, typed inputs, FK selects).
345pub fn resource_form_tsx() -> String {
346    include_str!("assets/ResourceForm.tsx").to_string()
347}
348
349/// `admin/src/resources.ts` — the descriptors generated from the manifest (ADR-006): one entry
350/// per resource with its editable fields (each field carrying its kind, and a `ref` target for
351/// foreign keys) and a Zod schema mirroring the backend validation.
352pub fn resources_ts(manifest: &Manifest) -> anyhow::Result<String> {
353    let mut entries = String::new();
354    for module in &manifest.modules {
355        let model = module.model_spec()?;
356        let table = table_name(&model.name);
357
358        let mut fields_ts = String::new();
359        let mut schema_ts = String::new();
360        for f in &model.fields {
361            let kind = field_kind(&f.name, f.ty);
362            // A synthetic `<name>_id` foreign-key column references `belongs_to` target's table.
363            let ref_target = model
364                .relations
365                .iter()
366                .find(|r| r.fk_column() == f.name)
367                .map(|r| r.target.clone());
368            let ref_ts = match &ref_target {
369                Some(t) => format!(", ref: \"{t}\""),
370                None => String::new(),
371            };
372            fields_ts.push_str(&format!(
373                "      {{ name: \"{}\", kind: \"{kind}\"{ref_ts} }},\n",
374                f.name
375            ));
376            // A foreign key is a required select: say so instead of zod's "Invalid uuid".
377            let zod = if ref_target.is_some() {
378                "z.string().uuid(\"Choose one\")"
379            } else {
380                zod_for(kind)
381            };
382            schema_ts.push_str(&format!("      {}: {zod},\n", f.name));
383        }
384
385        entries.push_str(&format!(
386            "  {{\n    name: \"{name}\",\n    path: \"{table}\",\n    fields: [\n{fields_ts}    ],\n    createSchema: z.object({{\n{schema_ts}    }}),\n  }},\n",
387            name = model.name,
388        ));
389    }
390
391    Ok(format!(
392        r#"import {{ z }} from "zod";
393
394export type FieldKind =
395  | "string"
396  | "number"
397  | "boolean"
398  | "uuid"
399  | "datetime"
400  | "email"
401  | "password";
402
403export interface FieldDesc {{
404  name: string;
405  kind: FieldKind;
406  /** For a foreign key: the target resource path (e.g. "users"). */
407  ref?: string;
408}}
409
410export interface ResourceDesc {{
411  name: string;
412  path: string;
413  fields: FieldDesc[];
414  createSchema: z.ZodTypeAny;
415}}
416
417export const resources: ResourceDesc[] = [
418{entries}];
419"#
420    ))
421}
422
423/// The admin field kind for a model field: `email`/`password` are recognized by name (mirroring
424/// the backend's users validation), everything else maps from the scalar type.
425fn field_kind(name: &str, ty: FieldType) -> &'static str {
426    match name {
427        "email" => "email",
428        "password" => "password",
429        _ => match ty {
430            FieldType::String => "string",
431            FieldType::Bool => "boolean",
432            FieldType::I32 | FieldType::I64 | FieldType::F64 => "number",
433            FieldType::Uuid => "uuid",
434            FieldType::DateTime => "datetime",
435        },
436    }
437}
438
439/// The Zod validator for a field kind, mirroring the backend `validator` rules.
440fn zod_for(kind: &str) -> &'static str {
441    match kind {
442        "email" => "z.string().email()",
443        "password" => "z.string().min(8)",
444        "number" => "z.coerce.number()",
445        "boolean" => "z.boolean()",
446        "uuid" => "z.string().uuid()",
447        "datetime" => "z.string().min(1)",
448        _ => "z.string().min(1)",
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use gize_core::{Module, Relation};
456
457    fn manifest() -> Manifest {
458        let mut m = Manifest::new("blog");
459        m.upsert_module(Module {
460            name: "users".to_string(),
461            model: None,
462            fields: vec![
463                "name:String".to_string(),
464                "email:String".to_string(),
465                "password:String".to_string(),
466                "is_admin:bool".to_string(),
467            ],
468            belongs_to: vec![],
469        });
470        m.upsert_module(Module {
471            name: "posts".to_string(),
472            model: None,
473            fields: vec!["title:String".to_string()],
474            belongs_to: vec![Relation {
475                field: "author".to_string(),
476                target: "users".to_string(),
477            }],
478        });
479        m
480    }
481
482    #[test]
483    fn resources_ts_describes_each_resource() {
484        let ts = resources_ts(&manifest()).unwrap();
485        assert!(ts.contains("path: \"users\""));
486        assert!(ts.contains("path: \"posts\""));
487        assert!(ts.contains("{ name: \"email\", kind: \"email\" }"));
488        assert!(ts.contains("email: z.string().email()"));
489        assert!(ts.contains("password: z.string().min(8)"));
490        // the FK column is a uuid field carrying its ref target.
491        assert!(ts.contains("{ name: \"author_id\", kind: \"uuid\", ref: \"users\" }"));
492    }
493
494    #[test]
495    fn api_client_walks_every_page() {
496        let out = api_ts();
497        assert!(out.contains("const PER_PAGE = 100;"));
498        assert!(out.contains("?page=${page}&per_page=${PER_PAGE}"));
499        assert!(out.contains("list: listAll,"));
500    }
501
502    #[test]
503    fn api_errors_carry_their_status() {
504        let out = api_ts();
505        assert!(out.contains("export class ApiError extends Error"));
506        assert!(out.contains("throw new ApiError(res.status, body || String(res.status));"));
507        assert!(out.contains("export function statusOf(error: unknown)"));
508    }
509
510    #[test]
511    fn refused_requests_are_not_retried_and_end_an_expired_session() {
512        let app = app_tsx();
513        assert!(app.contains("queryCache: new QueryCache({ onError: endExpiredSession })"));
514        assert!(app.contains("mutationCache: new MutationCache({ onError: endExpiredSession })"));
515        assert!(app.contains("(status === undefined || status >= 500) && failures < 3"));
516        let resource = resource_tsx();
517        assert!(resource.contains("list.isError"));
518        assert!(resource.contains("You don't have permission to see"));
519        assert!(resource.contains("if (expired) throw (expired as PromiseRejectedResult).reason;"));
520    }
521
522    #[test]
523    fn bulk_delete_asks_before_deleting_like_a_single_delete() {
524        let resource = resource_tsx();
525        assert!(resource.contains(
526            "if (confirm(`Delete ${what}? This cannot be undone.`)) remove.mutate([...selected]);"
527        ));
528        // The only call that sends the batch is the confirmed one.
529        assert_eq!(resource.matches("remove.mutate(").count(), 1);
530        assert!(resource_form_tsx().contains("This cannot be undone."));
531    }
532
533    #[test]
534    fn labels_read_as_words_and_foreign_keys_explain_themselves() {
535        let api = api_ts();
536        assert!(api.contains(r#"s.replace(/_/g, " ")"#));
537        assert!(api.contains("export function nounOf(model: string)"));
538        let ts = resources_ts(&manifest()).unwrap();
539        assert!(ts.contains(r#"author_id: z.string().uuid("Choose one"),"#));
540    }
541
542    #[test]
543    fn package_json_uses_the_project_name() {
544        assert!(package_json("blog").contains("\"name\": \"blog-admin\""));
545    }
546
547    #[test]
548    fn pnpm_workspace_approves_esbuild_build_on_pnpm_10_and_11() {
549        // pnpm 10+ blocks dependency build scripts and pnpm 11 fails the install on one, so the
550        // first-run `pnpm install` behind `gize serve` needs esbuild approved in both spellings.
551        let ws = pnpm_workspace();
552        assert!(ws.contains("allowBuilds:\n  esbuild: true\n"));
553        assert!(ws.contains("onlyBuiltDependencies:\n  - esbuild\n"));
554        assert!(ws.starts_with("packages:\n"));
555        // pnpm 11 warns about, and ignores, the old `pnpm` field in package.json.
556        assert!(!package_json("blog").contains("\"pnpm\""));
557    }
558}