rastack 0.0.44 → 0.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.45](https://github.com/theserverkid/reactapistack/compare/v0.0.44...v0.0.45) (2026-07-13)
6
+
7
+
8
+ ### Features
9
+
10
+ * **admin:** rebuild `rastack admin` as the dogfooded React console with Linear-style filters ([8755fb5](https://github.com/theserverkid/reactapistack/commit/8755fb5e8ce239851e5cd08b15045621419046b9))
11
+
5
12
  ### [0.0.44](https://github.com/theserverkid/reactapistack/compare/v0.0.43...v0.0.44) (2026-07-12)
6
13
 
7
14
 
@@ -1,31 +1,49 @@
1
1
  /**
2
- * The in-browser admin — a Django-admin-style, manifest-driven UI that runs
3
- * entirely over the WASM engine (`useRastackClient().engine`). It reuses the
4
- * headless `rastack/components` (`DataTable`, `AutoForm`, primitives) and is
5
- * app-generic: every screen is built from the compiled manifest, so the same
6
- * bundle admins any app. Faithful to `rust/rastack-api/src/admin.rs`.
2
+ * The admin — a full Django-admin-style, manifest-driven console, built entirely
3
+ * from React API Stack's own pieces: the headless `rastack/components`
4
+ * (`DataTable`, `AutoForm`, `Button`, `Card`, `Badge`…), the `rastack/theme`
5
+ * tokens, and the framework's list/detail contract. It is app-generic (every
6
+ * screen is derived from the compiled manifest) and transport-agnostic: the
7
+ * `engine` prop is the WASM engine under `rastack dev` or an HTTP engine to the
8
+ * real `rastack-server` under `rastack admin` — the UI never learns which.
9
+ *
10
+ * Layout: models on the left (`<Sidebar>`), the changelist table in the main
11
+ * area, a Linear-style filter bar on top (`<FilterBar>`), and add/change forms.
7
12
  */
8
-
9
13
  import React, { useEffect, useMemo, useState } from "react";
10
- import { useRastackClient } from "../provider";
11
14
  import {
15
+ AutoForm,
12
16
  Button,
13
17
  Card,
14
18
  DataTable,
15
- AutoForm,
16
19
  Spinner,
17
20
  type DataTableColumnInput,
18
21
  } from "../components";
22
+ import type { RastackEngine } from "../provider/types";
19
23
  import {
20
24
  AdminResource,
21
25
  adminResources,
22
26
  changelistHeaders,
23
- detailPath,
24
27
  findResource,
25
- parseManifest,
26
28
  } from "./adapters";
27
- import { useDeleteRecord, useResourceForm, useResourceTable } from "./useAdmin";
28
- import type { RastackEngine } from "../provider/types";
29
+ import {
30
+ useDeleteRecord,
31
+ useFilterModel,
32
+ useResourceForm,
33
+ useResourceTable,
34
+ } from "./useAdmin";
35
+ import { Sidebar } from "./ui/Sidebar";
36
+ import { FilterBar } from "./ui/FilterBar";
37
+ import { AdminStyles } from "./ui/styles";
38
+ import {
39
+ IconArrowLeft,
40
+ IconChevronRight,
41
+ IconInbox,
42
+ IconLink,
43
+ IconPlus,
44
+ IconTable,
45
+ IconTrash,
46
+ } from "./ui/icons";
29
47
 
30
48
  // -- hash routing (dependency-free) -----------------------------------------
31
49
 
@@ -60,78 +78,121 @@ const go = (hash: string) => {
60
78
  location.hash = hash;
61
79
  };
62
80
 
63
- // -- shell ------------------------------------------------------------------
81
+ // -- chrome -----------------------------------------------------------------
64
82
 
65
- function Shell(props: {
66
- crumbs: { label: string; hash?: string }[];
67
- children: React.ReactNode;
68
- }): React.JSX.Element {
83
+ interface Crumb {
84
+ label: string;
85
+ hash?: string;
86
+ }
87
+
88
+ function Topbar({ crumbs }: { crumbs: Crumb[] }): React.JSX.Element {
69
89
  return (
70
- <div style={{ maxWidth: 1024, margin: "0 auto", padding: "24px 20px" }}>
71
- <nav style={{ marginBottom: 20, fontSize: 14 }}>
72
- {props.crumbs.map((c, i) => (
73
- <span key={i}>
74
- {i > 0 ? <span style={{ opacity: 0.5 }}> / </span> : null}
90
+ <header className="ras-topbar">
91
+ <nav className="ras-crumbs">
92
+ {crumbs.map((c, i) => (
93
+ <React.Fragment key={i}>
94
+ {i > 0 ? <IconChevronRight size={13} className="ras-crumb-sep" /> : null}
75
95
  {c.hash ? (
76
- <a href={c.hash} style={{ textDecoration: "none" }}>
77
- {c.label}
78
- </a>
96
+ <a href={c.hash}>{c.label}</a>
79
97
  ) : (
80
- <span>{c.label}</span>
98
+ <span className="ras-crumb-current">{c.label}</span>
81
99
  )}
82
- </span>
100
+ </React.Fragment>
83
101
  ))}
84
102
  </nav>
85
- {props.children}
103
+ </header>
104
+ );
105
+ }
106
+
107
+ function PageHead(props: {
108
+ eyebrow?: string;
109
+ title: string;
110
+ subtitle?: React.ReactNode;
111
+ actions?: React.ReactNode;
112
+ }): React.JSX.Element {
113
+ return (
114
+ <div className="ras-pagehead">
115
+ <div>
116
+ {props.eyebrow ? <div className="ras-eyebrow">{props.eyebrow}</div> : null}
117
+ <h1 className="ras-title">{props.title}</h1>
118
+ {props.subtitle ? <div className="ras-subtitle">{props.subtitle}</div> : null}
119
+ </div>
120
+ {props.actions ? <div>{props.actions}</div> : null}
86
121
  </div>
87
122
  );
88
123
  }
89
124
 
90
- // -- screens ----------------------------------------------------------------
125
+ // -- index / overview -------------------------------------------------------
91
126
 
92
127
  function IndexScreen(props: { resources: AdminResource[] }): React.JSX.Element {
93
128
  const byApp = useMemo(() => {
94
129
  const map = new Map<string, AdminResource[]>();
95
130
  for (const r of adminResources(props.resources)) {
96
- (map.get(r.app) ?? map.set(r.app, []).get(r.app)!).push(r);
131
+ const list = map.get(r.app) ?? [];
132
+ list.push(r);
133
+ map.set(r.app, list);
97
134
  }
98
135
  return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
99
136
  }, [props.resources]);
100
137
 
101
138
  return (
102
- <Shell crumbs={[{ label: "Admin" }]}>
103
- <h1 style={{ fontSize: 22, margin: "0 0 16px" }}>Administration</h1>
104
- {byApp.map(([app, models]) => (
105
- <Card key={app} style={{ marginBottom: 16, padding: 16 }}>
106
- <h2 style={{ fontSize: 15, textTransform: "capitalize", margin: "0 0 8px" }}>
107
- {app}
108
- </h2>
109
- <ul style={{ margin: 0, paddingLeft: 18 }}>
110
- {models
111
- .slice()
112
- .sort((a, b) => a.model.localeCompare(b.model))
113
- .map((r) => (
114
- <li key={r.key} style={{ margin: "4px 0" }}>
115
- <a href={`#/${r.app}/${r.model}/`}>{r.label}</a>
116
- </li>
117
- ))}
118
- </ul>
119
- </Card>
120
- ))}
121
- </Shell>
139
+ <>
140
+ <Topbar crumbs={[{ label: "Overview" }]} />
141
+ <div className="ras-content">
142
+ <PageHead
143
+ eyebrow="React API Stack"
144
+ title="Administration"
145
+ subtitle="Browse and edit every model in the manifest — stored as Apache Iceberg tables on object storage."
146
+ />
147
+ {byApp.map(([app, models]) => (
148
+ <div key={app} style={{ marginBottom: 28 }}>
149
+ <div className="ras-nav-label" style={{ padding: "0 2px 10px" }}>
150
+ {app}
151
+ </div>
152
+ <div className="ras-cardgrid">
153
+ {models
154
+ .slice()
155
+ .sort((a, b) => a.model.localeCompare(b.model))
156
+ .map((r) => (
157
+ <Card
158
+ key={r.key}
159
+ className="ras-modelcard"
160
+ onClick={() => go(`#/${r.app}/${r.model}/`)}
161
+ >
162
+ <div className="ras-mc-top">
163
+ <span className="ras-mc-icon">
164
+ <IconTable size={19} />
165
+ </span>
166
+ </div>
167
+ <div className="ras-mc-name">{r.label}</div>
168
+ <div className="ras-mc-go">
169
+ View records <IconChevronRight size={14} />
170
+ </div>
171
+ </Card>
172
+ ))}
173
+ </div>
174
+ </div>
175
+ ))}
176
+ </div>
177
+ </>
122
178
  );
123
179
  }
124
180
 
125
- /** DataTable columns with id + FK cells rendered as links (like the Rust admin). */
181
+ // -- changelist -------------------------------------------------------------
182
+
183
+ /** Changelist columns: mono id link, FK chips, boolean dots — everything else default. */
126
184
  function columnsFor(resource: AdminResource): DataTableColumnInput[] {
127
185
  return changelistHeaders(resource).map((h) => {
128
186
  if (h.key === "id") {
129
187
  return {
130
188
  key: "id",
131
- header: "id",
132
- render: (row: Record<string, unknown>, value: unknown) => (
133
- <a href={`#/${resource.app}/${resource.model}/${String(value)}`}>
134
- {String(value)}
189
+ header: "ID",
190
+ render: (row: Record<string, unknown>) => (
191
+ <a
192
+ className="ras-cell-link ras-id"
193
+ href={`#/${resource.app}/${resource.model}/${String(row.id)}`}
194
+ >
195
+ {String(row.id)}
135
196
  </a>
136
197
  ),
137
198
  };
@@ -144,14 +205,36 @@ function columnsFor(resource: AdminResource): DataTableColumnInput[] {
144
205
  header: h.name,
145
206
  render: (_row: Record<string, unknown>, value: unknown) =>
146
207
  value == null || value === "" ? (
147
- ""
208
+ <span className="ras-muted">—</span>
148
209
  ) : (
149
- <a href={`#/${app}/${model}/${String(value)}`}>
210
+ <a
211
+ className="ras-fk"
212
+ href={`#/${app}/${model}/${String(value)}`}
213
+ onClick={(e) => e.stopPropagation()}
214
+ >
215
+ <IconLink size={12} />
150
216
  {model} #{String(value)}
151
217
  </a>
152
218
  ),
153
219
  };
154
220
  }
221
+ if (field?.type === "bool") {
222
+ return {
223
+ key: h.key,
224
+ header: h.name,
225
+ render: (_row: Record<string, unknown>, value: unknown) => (
226
+ <span className="ras-bool">
227
+ <span
228
+ className="ras-dot"
229
+ style={{
230
+ background: value ? "var(--color-success)" : "var(--color-text-muted)",
231
+ }}
232
+ />
233
+ {value ? "True" : "False"}
234
+ </span>
235
+ ),
236
+ };
237
+ }
155
238
  return { key: h.key, header: h.name };
156
239
  });
157
240
  }
@@ -161,39 +244,80 @@ function ListScreen(props: {
161
244
  resource: AdminResource;
162
245
  }): React.JSX.Element {
163
246
  const { engine, resource } = props;
164
- const { query } = useResourceTable(engine, resource);
247
+ const table = useResourceTable(engine, resource);
248
+ const { fields: facets } = useFilterModel(engine, resource);
165
249
  const columns = useMemo(() => columnsFor(resource), [resource.key]);
166
- return (
167
- <Shell
168
- crumbs={[
169
- { label: "Admin", hash: "#/" },
170
- { label: resource.label },
171
- ]}
172
- >
173
- <div
174
- style={{
175
- display: "flex",
176
- justifyContent: "space-between",
177
- alignItems: "center",
178
- marginBottom: 12,
179
- }}
180
- >
181
- <h1 style={{ fontSize: 20, margin: 0 }}>{resource.label}</h1>
182
- <Button onClick={() => go(`#/${resource.app}/${resource.model}/add`)}>
183
- Add {resource.label}
184
- </Button>
250
+ const addHash = `#/${resource.app}/${resource.model}/add`;
251
+
252
+ const empty = (
253
+ <div className="ras-empty">
254
+ <span className="ras-empty-ico">
255
+ <IconInbox size={24} />
256
+ </span>
257
+ <div>
258
+ <h3>Nothing here yet</h3>
259
+ <div style={{ marginTop: 4 }}>
260
+ {Object.keys(table.filters).length || table.search
261
+ ? "No records match the current filters."
262
+ : `Add the first ${resource.label.toLowerCase()} to get started.`}
263
+ </div>
185
264
  </div>
186
- <DataTable
187
- query={query}
188
- columns={columns}
189
- onRowClick={(row) =>
190
- go(`#/${resource.app}/${resource.model}/${String(row.id)}`)
191
- }
265
+ <Button size="sm" onClick={() => go(addHash)}>
266
+ <IconPlus size={15} /> Add {resource.label.toLowerCase()}
267
+ </Button>
268
+ </div>
269
+ );
270
+
271
+ return (
272
+ <>
273
+ <Topbar
274
+ crumbs={[{ label: "Overview", hash: "#/" }, { label: resource.label }]}
192
275
  />
193
- </Shell>
276
+ <div className="ras-content">
277
+ <PageHead
278
+ eyebrow={resource.app}
279
+ title={resource.label}
280
+ subtitle={
281
+ table.query.isLoading
282
+ ? "Loading…"
283
+ : `${table.itemCount} ${table.itemCount === 1 ? "record" : "records"}`
284
+ }
285
+ actions={
286
+ <Button onClick={() => go(addHash)}>
287
+ <IconPlus size={16} /> Add {resource.label.toLowerCase()}
288
+ </Button>
289
+ }
290
+ />
291
+
292
+ <FilterBar
293
+ facets={facets}
294
+ filters={table.filters}
295
+ setFilter={table.setFilter}
296
+ removeFilter={table.removeFilter}
297
+ clearFilters={table.clearFilters}
298
+ search={table.search}
299
+ setSearch={table.setSearch}
300
+ searchable={resource.search.length > 0}
301
+ />
302
+
303
+ <Card flush className="ras-tablecard">
304
+ <DataTable
305
+ query={table.query}
306
+ columns={columns}
307
+ searchable={false}
308
+ emptyMessage={empty}
309
+ onRowClick={(row) =>
310
+ go(`#/${resource.app}/${resource.model}/${String(row.id)}`)
311
+ }
312
+ />
313
+ </Card>
314
+ </div>
315
+ </>
194
316
  );
195
317
  }
196
318
 
319
+ // -- add / change -----------------------------------------------------------
320
+
197
321
  function FormScreen(props: {
198
322
  engine: RastackEngine;
199
323
  resource: AdminResource;
@@ -208,101 +332,130 @@ function FormScreen(props: {
208
332
  const del = useDeleteRecord(engine, resource);
209
333
  const listHash = `#/${resource.app}/${resource.model}/`;
210
334
 
211
- // On successful save, return to the changelist.
212
335
  useEffect(() => {
213
336
  if (done) go(listHash);
214
337
  }, [done]);
215
338
 
216
339
  const onDelete = async () => {
217
340
  if (!id) return;
218
- if (typeof confirm === "function" && !confirm("Delete this record?")) return;
341
+ if (typeof confirm === "function" && !confirm(`Delete this ${resource.label.toLowerCase()}?`))
342
+ return;
219
343
  if (await del(id)) go(listHash);
220
344
  };
221
345
 
222
346
  return (
223
- <Shell
224
- crumbs={[
225
- { label: "Admin", hash: "#/" },
226
- { label: resource.label, hash: listHash },
227
- { label: id ? `Edit #${id}` : "Add" },
228
- ]}
229
- >
230
- <h1 style={{ fontSize: 20, margin: "0 0 16px" }}>
231
- {id ? `Edit ${resource.label} #${id}` : `Add ${resource.label}`}
232
- </h1>
233
- {loadError ? (
234
- <p style={{ color: "#c93c37" }}>{loadError}</p>
235
- ) : loading ? (
236
- <Spinner />
237
- ) : (
238
- <Card style={{ padding: 20, maxWidth: 560 }}>
239
- <AutoForm form={model} onCancel={() => go(listHash)} />
240
- {submitError ? (
241
- <p style={{ color: "#c93c37", marginTop: 12 }}>{submitError}</p>
242
- ) : null}
243
- {id ? (
244
- <div style={{ marginTop: 16 }}>
245
- <Button variant="danger" onClick={onDelete}>
246
- Delete
247
- </Button>
248
- </div>
249
- ) : null}
250
- </Card>
251
- )}
252
- </Shell>
347
+ <>
348
+ <Topbar
349
+ crumbs={[
350
+ { label: "Overview", hash: "#/" },
351
+ { label: resource.label, hash: listHash },
352
+ { label: id ? `Edit #${id}` : "Add" },
353
+ ]}
354
+ />
355
+ <div className="ras-content">
356
+ <PageHead
357
+ eyebrow={resource.label}
358
+ title={id ? `Edit ${resource.label.toLowerCase()}` : `Add ${resource.label.toLowerCase()}`}
359
+ subtitle={id ? `Record #${id}` : "Create a new record."}
360
+ />
361
+
362
+ {loadError ? (
363
+ <Card style={{ padding: 20, borderColor: "var(--color-danger)" }}>
364
+ <span style={{ color: "var(--color-danger)" }}>{loadError}</span>
365
+ </Card>
366
+ ) : loading ? (
367
+ <div className="ras-boot" style={{ minHeight: 160 }}>
368
+ <Spinner /> Loading…
369
+ </div>
370
+ ) : (
371
+ <>
372
+ <Card className="ras-formgrid" style={{ padding: 22 }}>
373
+ <AutoForm
374
+ form={model}
375
+ onCancel={() => go(listHash)}
376
+ submitLabel={id ? "Save changes" : `Create ${resource.label.toLowerCase()}`}
377
+ />
378
+ {submitError ? (
379
+ <div style={{ color: "var(--color-danger)", marginTop: 14, fontSize: 13 }}>
380
+ {submitError}
381
+ </div>
382
+ ) : null}
383
+ </Card>
384
+
385
+ {id ? (
386
+ <div className="ras-formgrid ras-danger-zone">
387
+ <div>
388
+ <div className="ras-dz-title">Delete this {resource.label.toLowerCase()}</div>
389
+ <div className="ras-dz-sub">This permanently removes the record from the table.</div>
390
+ </div>
391
+ <Button variant="danger" onClick={onDelete}>
392
+ <IconTrash size={15} /> Delete
393
+ </Button>
394
+ </div>
395
+ ) : null}
396
+ </>
397
+ )}
398
+ </div>
399
+ </>
253
400
  );
254
401
  }
255
402
 
256
403
  // -- root -------------------------------------------------------------------
257
404
 
258
405
  export function AdminApp(props: {
259
- manifest: object | string;
406
+ engine: RastackEngine;
407
+ resources: AdminResource[];
260
408
  }): React.JSX.Element {
261
- const client = useRastackClient();
262
409
  const route = useRoute();
263
- const resources = useMemo(() => parseManifest(props.manifest), [props.manifest]);
410
+ const { engine, resources } = props;
264
411
 
265
- if (client.status === "error") {
266
- return (
267
- <Shell crumbs={[{ label: "Admin" }]}>
268
- <p style={{ color: "#c93c37" }}>
269
- The WASM engine failed to start. Check the console.
270
- </p>
271
- </Shell>
272
- );
273
- }
274
- if (!client.engine || client.status !== "ready") {
275
- return (
276
- <Shell crumbs={[{ label: "Admin" }]}>
277
- <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
278
- <Spinner />
279
- <span>
280
- {client.status === "seeding"
281
- ? "Seeding the local warehouse…"
282
- : "Starting the WASM engine…"}
283
- </span>
284
- </div>
285
- </Shell>
286
- );
287
- }
288
-
289
- if (route.name === "index") return <IndexScreen resources={resources} />;
412
+ const activeKey =
413
+ route.name === "index" ? undefined : `${route.app}.${route.model}`;
290
414
 
291
- const resource = findResource(resources, route.app, route.model);
292
- if (!resource) {
293
- return (
294
- <Shell crumbs={[{ label: "Admin", hash: "#/" }, { label: "Not found" }]}>
295
- <p>
296
- Unknown resource <code>{route.app}.{route.model}</code>.
297
- </p>
298
- </Shell>
299
- );
415
+ let screen: React.ReactNode;
416
+ if (route.name === "index") {
417
+ screen = <IndexScreen resources={resources} />;
418
+ } else {
419
+ const resource = findResource(resources, route.app, route.model);
420
+ if (!resource) {
421
+ screen = (
422
+ <>
423
+ <Topbar crumbs={[{ label: "Overview", hash: "#/" }, { label: "Not found" }]} />
424
+ <div className="ras-content">
425
+ <div className="ras-empty">
426
+ <span className="ras-empty-ico">
427
+ <IconArrowLeft size={22} />
428
+ </span>
429
+ <h3>Unknown resource</h3>
430
+ <div>
431
+ <code>
432
+ {route.app}.{route.model}
433
+ </code>{" "}
434
+ is not in the manifest.
435
+ </div>
436
+ <Button size="sm" variant="outline" onClick={() => go("#/")}>
437
+ Back to overview
438
+ </Button>
439
+ </div>
440
+ </div>
441
+ </>
442
+ );
443
+ } else if (route.name === "list") {
444
+ screen = <ListScreen engine={engine} resource={resource} />;
445
+ } else if (route.name === "add") {
446
+ screen = <FormScreen engine={engine} resource={resource} />;
447
+ } else {
448
+ screen = <FormScreen engine={engine} resource={resource} id={route.id} />;
449
+ }
300
450
  }
301
- if (route.name === "list")
302
- return <ListScreen engine={client.engine} resource={resource} />;
303
- if (route.name === "add")
304
- return <FormScreen engine={client.engine} resource={resource} />;
451
+
305
452
  return (
306
- <FormScreen engine={client.engine} resource={resource} id={route.id} />
453
+ <>
454
+ <AdminStyles />
455
+ <div className="ras-root">
456
+ <Sidebar resources={resources} activeKey={activeKey} />
457
+ <main className="ras-main">{screen}</main>
458
+ </div>
459
+ </>
307
460
  );
308
461
  }