1use gize_core::naming::table_name;
14use gize_core::{FieldType, Manifest};
15
16pub 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
51pub 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
74pub 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
100pub 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
124pub 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
130pub fn gitignore() -> String {
132 "node_modules\ndist\n.env\n".to_string()
133}
134
135pub fn styles_css() -> String {
137 include_str!("assets/styles.css").to_string()
138}
139
140pub 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
156pub 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
192pub fn icons_tsx() -> String {
194 include_str!("assets/icons.tsx").to_string()
195}
196
197pub 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
232pub 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
309pub fn auth_tsx() -> String {
311 include_str!("assets/auth.tsx").to_string()
312}
313
314pub fn app_tsx() -> String {
316 include_str!("assets/App.tsx").to_string()
317}
318
319pub fn resource_tsx() -> String {
321 include_str!("assets/Resource.tsx").to_string()
322}
323
324pub fn resource_form_tsx() -> String {
326 include_str!("assets/ResourceForm.tsx").to_string()
327}
328
329pub 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 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 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
403fn 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
419fn 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 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 assert!(package_json("blog").contains("\"onlyBuiltDependencies\": [\"esbuild\"]"));
501 }
502}