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