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 + a Zod schema) per resource from `gize.toml`, and a single generic `Resource`
6//! component renders 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//! Templates are Rust functions returning file contents (like `gize-templates`); the
10//! generator (`gize-generator`) assembles them into a `Plan`.
11
12use gize_core::naming::table_name;
13use gize_core::{FieldType, Manifest};
14
15/// `admin/package.json` — the SPA's dependencies and scripts.
16pub fn package_json(project: &str) -> String {
17    format!(
18        r#"{{
19  "name": "{project}-admin",
20  "private": true,
21  "type": "module",
22  "scripts": {{
23    "dev": "vite",
24    "build": "tsc --noEmit && vite build",
25    "preview": "vite preview"
26  }},
27  "dependencies": {{
28    "@hookform/resolvers": "^3.9.0",
29    "@tanstack/react-query": "^5.59.0",
30    "react": "^18.3.1",
31    "react-dom": "^18.3.1",
32    "react-hook-form": "^7.53.0",
33    "zod": "^3.23.8"
34  }},
35  "devDependencies": {{
36    "@tailwindcss/vite": "^4.0.0",
37    "@types/react": "^18.3.11",
38    "@types/react-dom": "^18.3.1",
39    "@vitejs/plugin-react": "^4.3.2",
40    "tailwindcss": "^4.0.0",
41    "typescript": "^5.6.2",
42    "vite": "^5.4.8"
43  }}
44}}
45"#
46    )
47}
48
49/// `admin/vite.config.ts` — React + Tailwind, and a dev proxy so `/api/*` reaches the backend
50/// (avoids CORS in development; ADR-006).
51pub fn vite_config() -> String {
52    r#"import { defineConfig } from "vite";
53import react from "@vitejs/plugin-react";
54import tailwindcss from "@tailwindcss/vite";
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(), tailwindcss()],
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`.
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  </head>
109  <body>
110    <div id="root"></div>
111    <script type="module" src="/src/main.tsx"></script>
112  </body>
113</html>
114"#
115    .to_string()
116}
117
118/// `admin/.env.example`.
119pub fn env_example() -> String {
120    "# Point at your running Gize backend (proxied under /api in dev).\nVITE_API_URL=http://localhost:8080\n"
121        .to_string()
122}
123
124/// `admin/.gitignore`.
125pub fn gitignore() -> String {
126    "node_modules\ndist\n.env\n".to_string()
127}
128
129/// `admin/src/styles.css` — Tailwind v4 entry.
130pub fn styles_css() -> String {
131    "@import \"tailwindcss\";\n".to_string()
132}
133
134/// `admin/src/main.tsx`.
135pub fn main_tsx() -> String {
136    r#"import React from "react";
137import { createRoot } from "react-dom/client";
138import App from "./App";
139import "./styles.css";
140
141createRoot(document.getElementById("root")!).render(
142  <React.StrictMode>
143    <App />
144  </React.StrictMode>,
145);
146"#
147    .to_string()
148}
149
150/// `admin/src/api.ts` — a tiny typed fetch client that attaches the JWT and hits `/api`.
151pub fn api_ts() -> String {
152    r#"const BASE = "/api";
153
154export function getToken(): string | null {
155  return localStorage.getItem("gize_token");
156}
157
158async function req(path: string, opts: RequestInit = {}): Promise<any> {
159  const headers: Record<string, string> = {
160    "content-type": "application/json",
161    ...(opts.headers as Record<string, string>),
162  };
163  const t = getToken();
164  if (t) headers["authorization"] = `Bearer ${t}`;
165  const res = await fetch(`${BASE}${path}`, { ...opts, headers });
166  if (!res.ok) {
167    const body = await res.text();
168    throw new Error(body || String(res.status));
169  }
170  if (res.status === 204) return null;
171  return res.json();
172}
173
174export const api = {
175  list: (path: string) => req(`/${path}`),
176  get: (path: string, id: string) => req(`/${path}/${id}`),
177  create: (path: string, body: unknown) =>
178    req(`/${path}`, { method: "POST", body: JSON.stringify(body) }),
179  update: (path: string, id: string, body: unknown) =>
180    req(`/${path}/${id}`, { method: "PUT", body: JSON.stringify(body) }),
181  remove: (path: string, id: string) => req(`/${path}/${id}`, { method: "DELETE" }),
182  login: (email: string, password: string) =>
183    req(`/users/login`, { method: "POST", body: JSON.stringify({ email, password }) }),
184};
185"#
186    .to_string()
187}
188
189/// `admin/src/auth.tsx` — token storage hook and a login screen.
190pub fn auth_tsx() -> String {
191    r#"import { useState } from "react";
192import { api } from "./api";
193
194export function useAuth() {
195  const [token, setToken] = useState<string | null>(() => localStorage.getItem("gize_token"));
196  function login(t: string) {
197    localStorage.setItem("gize_token", t);
198    setToken(t);
199  }
200  function logout() {
201    localStorage.removeItem("gize_token");
202    setToken(null);
203  }
204  return { token, login, logout };
205}
206
207export function Login({ onLogin }: { onLogin: (token: string) => void }) {
208  const [email, setEmail] = useState("");
209  const [password, setPassword] = useState("");
210  const [error, setError] = useState("");
211
212  async function submit(e: React.FormEvent) {
213    e.preventDefault();
214    setError("");
215    try {
216      const res = await api.login(email, password);
217      onLogin(res.token);
218    } catch {
219      setError("Invalid credentials");
220    }
221  }
222
223  return (
224    <div className="min-h-screen flex items-center justify-center bg-gray-50">
225      <form onSubmit={submit} className="bg-white p-8 rounded-lg shadow w-80 space-y-4">
226        <h1 className="text-xl font-bold">Sign in</h1>
227        <input
228          className="w-full border rounded px-3 py-2"
229          placeholder="Email"
230          value={email}
231          onChange={(e) => setEmail(e.target.value)}
232        />
233        <input
234          className="w-full border rounded px-3 py-2"
235          type="password"
236          placeholder="Password"
237          value={password}
238          onChange={(e) => setPassword(e.target.value)}
239        />
240        {error && <p className="text-red-600 text-sm">{error}</p>}
241        <button className="w-full bg-black text-white rounded py-2" type="submit">
242          Sign in
243        </button>
244      </form>
245    </div>
246  );
247}
248"#
249    .to_string()
250}
251
252/// `admin/src/App.tsx` — the shell: auth gate, a sidebar of resources, and the active screen.
253pub fn app_tsx() -> String {
254    r#"import { useState } from "react";
255import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
256import { resources } from "./resources";
257import { Resource } from "./Resource";
258import { Login, useAuth } from "./auth";
259
260const client = new QueryClient();
261
262export default function App() {
263  const auth = useAuth();
264  const [active, setActive] = useState(resources[0]?.path ?? "");
265
266  if (!auth.token) return <Login onLogin={auth.login} />;
267
268  const current = resources.find((r) => r.path === active) ?? resources[0];
269
270  return (
271    <QueryClientProvider client={client}>
272      <div className="min-h-screen flex bg-gray-50 text-gray-900">
273        <aside className="w-56 bg-white border-r p-4 flex flex-col gap-1">
274          <div className="font-bold mb-3">Admin</div>
275          {resources.map((r) => (
276            <button
277              key={r.path}
278              onClick={() => setActive(r.path)}
279              className={`text-left px-3 py-2 rounded ${
280                current && r.path === current.path ? "bg-gray-900 text-white" : "hover:bg-gray-100"
281              }`}
282            >
283              {r.name}
284            </button>
285          ))}
286          <button onClick={auth.logout} className="mt-auto text-sm text-gray-500 px-3 py-2">
287            Sign out
288          </button>
289        </aside>
290        <main className="flex-1 p-8">
291          {current && <Resource key={current.path} desc={current} />}
292        </main>
293      </div>
294    </QueryClientProvider>
295  );
296}
297"#
298    .to_string()
299}
300
301/// `admin/src/Resource.tsx` — the generic CRUD screen (list + search + pagination + a form for
302/// create/edit + delete), driven by a resource descriptor.
303pub fn resource_tsx() -> String {
304    r#"import { useState } from "react";
305import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
306import { useForm } from "react-hook-form";
307import { zodResolver } from "@hookform/resolvers/zod";
308import { api } from "./api";
309import type { ResourceDesc } from "./resources";
310
311type Row = Record<string, any>;
312
313export function Resource({ desc }: { desc: ResourceDesc }) {
314  const qc = useQueryClient();
315  const [editing, setEditing] = useState<Row | null>(null);
316  const [search, setSearch] = useState("");
317  const [page, setPage] = useState(0);
318  const pageSize = 10;
319
320  const list = useQuery<Row[]>({ queryKey: [desc.path], queryFn: () => api.list(desc.path) });
321  const form = useForm<Row>({ resolver: zodResolver(desc.createSchema) as any });
322
323  const save = useMutation({
324    mutationFn: (values: Row) =>
325      editing ? api.update(desc.path, editing.id, values) : api.create(desc.path, values),
326    onSuccess: () => {
327      qc.invalidateQueries({ queryKey: [desc.path] });
328      form.reset({});
329      setEditing(null);
330    },
331  });
332
333  const remove = useMutation({
334    mutationFn: (id: string) => api.remove(desc.path, id),
335    onSuccess: () => qc.invalidateQueries({ queryKey: [desc.path] }),
336  });
337
338  const items = list.data ?? [];
339  const filtered = items.filter((it) =>
340    JSON.stringify(it).toLowerCase().includes(search.toLowerCase()),
341  );
342  const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
343  const shown = filtered.slice(page * pageSize, page * pageSize + pageSize);
344  const columns = desc.fields.filter((f) => f.kind !== "password");
345
346  function startEdit(item: Row) {
347    setEditing(item);
348    const values: Row = {};
349    for (const f of desc.fields) values[f.name] = item[f.name] ?? "";
350    form.reset(values);
351  }
352  function startCreate() {
353    setEditing(null);
354    form.reset({});
355  }
356
357  return (
358    <div className="space-y-6">
359      <div className="flex items-center gap-3">
360        <h1 className="text-2xl font-bold">{desc.name}</h1>
361        <input
362          className="border rounded px-3 py-1 ml-auto"
363          placeholder="Search"
364          value={search}
365          onChange={(e) => {
366            setSearch(e.target.value);
367            setPage(0);
368          }}
369        />
370        <button className="bg-gray-900 text-white rounded px-3 py-1" onClick={startCreate}>
371          New
372        </button>
373      </div>
374
375      <form
376        onSubmit={form.handleSubmit((v) => save.mutate(v))}
377        className="bg-white rounded-lg shadow p-4 grid grid-cols-2 gap-3"
378        data-testid="resource-form"
379      >
380        <div className="col-span-2 font-semibold">{editing ? "Edit" : "Create"}</div>
381        {desc.fields.map((f) => (
382          <label key={f.name} className="text-sm flex flex-col gap-1">
383            {f.name}
384            {f.kind === "boolean" ? (
385              <input type="checkbox" {...form.register(f.name)} />
386            ) : (
387              <input
388                className="border rounded px-2 py-1"
389                type={f.kind === "number" ? "number" : f.kind === "password" ? "password" : "text"}
390                {...form.register(f.name)}
391              />
392            )}
393            {form.formState.errors[f.name] && (
394              <span className="text-red-600 text-xs">
395                {String(form.formState.errors[f.name]?.message ?? "invalid")}
396              </span>
397            )}
398          </label>
399        ))}
400        <div className="col-span-2 flex gap-2">
401          <button className="bg-black text-white rounded px-4 py-1" type="submit">
402            {editing ? "Save" : "Create"}
403          </button>
404          {editing && (
405            <button type="button" className="px-4 py-1" onClick={startCreate}>
406              Cancel
407            </button>
408          )}
409          {save.isError && (
410            <span className="text-red-600 text-sm self-center">{String(save.error)}</span>
411          )}
412        </div>
413      </form>
414
415      <div className="bg-white rounded-lg shadow overflow-x-auto">
416        <table className="w-full text-sm">
417          <thead className="bg-gray-100 text-left">
418            <tr>
419              {columns.map((c) => (
420                <th key={c.name} className="px-3 py-2">
421                  {c.name}
422                </th>
423              ))}
424              <th className="px-3 py-2" />
425            </tr>
426          </thead>
427          <tbody>
428            {shown.map((item) => (
429              <tr key={item.id} className="border-t" data-testid="row">
430                {columns.map((c) => (
431                  <td key={c.name} className="px-3 py-2">
432                    {String(item[c.name] ?? "")}
433                  </td>
434                ))}
435                <td className="px-3 py-2 text-right whitespace-nowrap">
436                  <button className="text-blue-600 mr-3" onClick={() => startEdit(item)}>
437                    Edit
438                  </button>
439                  <button className="text-red-600" onClick={() => remove.mutate(item.id)}>
440                    Delete
441                  </button>
442                </td>
443              </tr>
444            ))}
445          </tbody>
446        </table>
447      </div>
448
449      <div className="flex items-center gap-3 text-sm">
450        <button
451          disabled={page === 0}
452          onClick={() => setPage(page - 1)}
453          className="px-2 py-1 border rounded disabled:opacity-40"
454        >
455          Prev
456        </button>
457        <span>
458          Page {page + 1} / {pages}
459        </span>
460        <button
461          disabled={page + 1 >= pages}
462          onClick={() => setPage(page + 1)}
463          className="px-2 py-1 border rounded disabled:opacity-40"
464        >
465          Next
466        </button>
467      </div>
468    </div>
469  );
470}
471"#
472    .to_string()
473}
474
475/// `admin/src/resources.ts` — the descriptors generated from the manifest (ADR-006): one entry
476/// per resource with its editable fields and a Zod schema mirroring the backend validation.
477pub fn resources_ts(manifest: &Manifest) -> anyhow::Result<String> {
478    let mut entries = String::new();
479    for module in &manifest.modules {
480        let model = module.model_spec()?;
481        let table = table_name(&model.name);
482
483        let mut fields_ts = String::new();
484        let mut schema_ts = String::new();
485        for f in &model.fields {
486            let kind = field_kind(&f.name, f.ty);
487            fields_ts.push_str(&format!(
488                "      {{ name: \"{}\", kind: \"{kind}\" }},\n",
489                f.name
490            ));
491            schema_ts.push_str(&format!("      {}: {},\n", f.name, zod_for(kind)));
492        }
493
494        entries.push_str(&format!(
495            "  {{\n    name: \"{name}\",\n    path: \"{table}\",\n    fields: [\n{fields_ts}    ],\n    createSchema: z.object({{\n{schema_ts}    }}),\n  }},\n",
496            name = model.name,
497        ));
498    }
499
500    Ok(format!(
501        r#"import {{ z }} from "zod";
502
503export type FieldKind =
504  | "string"
505  | "number"
506  | "boolean"
507  | "uuid"
508  | "datetime"
509  | "email"
510  | "password";
511
512export interface FieldDesc {{
513  name: string;
514  kind: FieldKind;
515}}
516
517export interface ResourceDesc {{
518  name: string;
519  path: string;
520  fields: FieldDesc[];
521  createSchema: z.ZodTypeAny;
522}}
523
524export const resources: ResourceDesc[] = [
525{entries}];
526"#
527    ))
528}
529
530/// The admin field kind for a model field: `email`/`password` are recognized by name (mirroring
531/// the backend's users validation), everything else maps from the scalar type.
532fn field_kind(name: &str, ty: FieldType) -> &'static str {
533    match name {
534        "email" => "email",
535        "password" => "password",
536        _ => match ty {
537            FieldType::String => "string",
538            FieldType::Bool => "boolean",
539            FieldType::I32 | FieldType::I64 | FieldType::F64 => "number",
540            FieldType::Uuid => "uuid",
541            FieldType::DateTime => "datetime",
542        },
543    }
544}
545
546/// The Zod validator for a field kind, mirroring the backend `validator` rules.
547fn zod_for(kind: &str) -> &'static str {
548    match kind {
549        "email" => "z.string().email()",
550        "password" => "z.string().min(8)",
551        "number" => "z.coerce.number()",
552        "boolean" => "z.boolean()",
553        "uuid" => "z.string().uuid()",
554        "datetime" => "z.string().min(1)",
555        _ => "z.string().min(1)",
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use gize_core::{Module, Relation};
563
564    fn manifest() -> Manifest {
565        let mut m = Manifest::new("blog");
566        m.upsert_module(Module {
567            name: "users".to_string(),
568            fields: vec![
569                "name:String".to_string(),
570                "email:String".to_string(),
571                "password:String".to_string(),
572                "is_admin:bool".to_string(),
573            ],
574            belongs_to: vec![],
575        });
576        m.upsert_module(Module {
577            name: "posts".to_string(),
578            fields: vec!["title:String".to_string()],
579            belongs_to: vec![Relation {
580                field: "author".to_string(),
581                target: "users".to_string(),
582            }],
583        });
584        m
585    }
586
587    #[test]
588    fn resources_ts_describes_each_resource() {
589        let ts = resources_ts(&manifest()).unwrap();
590        assert!(ts.contains("path: \"users\""));
591        assert!(ts.contains("path: \"posts\""));
592        // email/password get the specialized kinds + Zod rules.
593        assert!(ts.contains("{ name: \"email\", kind: \"email\" }"));
594        assert!(ts.contains("email: z.string().email()"));
595        assert!(ts.contains("password: z.string().min(8)"));
596        // the FK column is present as a uuid field.
597        assert!(ts.contains("{ name: \"author_id\", kind: \"uuid\" }"));
598    }
599
600    #[test]
601    fn package_json_uses_the_project_name() {
602        assert!(package_json("blog").contains("\"name\": \"blog-admin\""));
603    }
604}