1use gize_core::naming::table_name;
13use gize_core::{FieldType, Manifest};
14
15pub 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
49pub 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
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 </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
118pub 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
124pub fn gitignore() -> String {
126 "node_modules\ndist\n.env\n".to_string()
127}
128
129pub fn styles_css() -> String {
131 "@import \"tailwindcss\";\n".to_string()
132}
133
134pub 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
150pub 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
189pub 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
252pub 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
301pub 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
475pub 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
530fn 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
546fn 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 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 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}