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