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}}
44"#
45 )
46}
47
48pub fn pnpm_workspace() -> String {
53 "packages:\n - \".\"\nallowBuilds:\n esbuild: true\nonlyBuiltDependencies:\n - esbuild\n"
54 .to_string()
55}
56
57pub 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
80pub 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
106pub 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
130pub 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
136pub fn gitignore() -> String {
138 "node_modules\ndist\n.env\n".to_string()
139}
140
141pub fn styles_css() -> String {
143 include_str!("assets/styles.css").to_string()
144}
145
146pub 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
162pub 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
198pub fn icons_tsx() -> String {
200 include_str!("assets/icons.tsx").to_string()
201}
202
203pub 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
238pub 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
329pub fn auth_tsx() -> String {
331 include_str!("assets/auth.tsx").to_string()
332}
333
334pub fn app_tsx() -> String {
336 include_str!("assets/App.tsx").to_string()
337}
338
339pub fn resource_tsx() -> String {
341 include_str!("assets/Resource.tsx").to_string()
342}
343
344pub fn resource_form_tsx() -> String {
346 include_str!("assets/ResourceForm.tsx").to_string()
347}
348
349pub 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 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 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
423fn 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
439fn 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 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 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 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 assert!(!package_json("blog").contains("\"pnpm\""));
557 }
558}