rastack 0.0.43 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/admin/AdminApp.tsx +461 -0
  3. package/admin/adapters.ts +346 -0
  4. package/admin/httpEngine.ts +56 -0
  5. package/admin/index.tsx +140 -0
  6. package/admin/ui/FilterBar.tsx +231 -0
  7. package/admin/ui/Popover.tsx +54 -0
  8. package/admin/ui/Sidebar.tsx +86 -0
  9. package/admin/ui/icons.tsx +146 -0
  10. package/admin/ui/styles.ts +398 -0
  11. package/admin/useAdmin.ts +434 -0
  12. package/dist/admin.js +459 -0
  13. package/dist/dev/harness.d.ts +70 -0
  14. package/dist/dev/harness.js +189 -0
  15. package/dist/rastack-admin.d.ts +14 -0
  16. package/dist/rastack-admin.js +128 -0
  17. package/dist/rastack-dev.d.ts +18 -0
  18. package/dist/rastack-dev.js +197 -0
  19. package/dist/rastack-wasm-build.d.ts +9 -4
  20. package/dist/rastack-wasm-build.js +44 -33
  21. package/dist/rastack.d.ts +2 -3
  22. package/dist/rastack.js +13 -32
  23. package/dist/wasm/package.json +4 -0
  24. package/dist/wasm/rastack_wasm.d.ts +100 -0
  25. package/dist/wasm/rastack_wasm.js +745 -0
  26. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  27. package/dist/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
  28. package/package.json +6 -2
  29. package/scripts/build-admin.js +48 -0
  30. package/scripts/copy-wasm.js +35 -0
  31. package/src/dev/harness.ts +174 -0
  32. package/src/rastack-admin.ts +112 -0
  33. package/src/rastack-dev.ts +197 -0
  34. package/src/rastack-wasm-build.ts +52 -41
  35. package/src/rastack.ts +14 -37
  36. package/test/dev.spec.ts +373 -0
  37. package/wasm/package.json +4 -0
  38. package/wasm/rastack_wasm.d.ts +100 -0
  39. package/wasm/rastack_wasm.js +745 -0
  40. package/wasm/rastack_wasm_bg.wasm +0 -0
  41. package/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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
+
12
+ ### [0.0.44](https://github.com/theserverkid/reactapistack/compare/v0.0.43...v0.0.44) (2026-07-12)
13
+
14
+
15
+ ### Features
16
+
17
+ * **dev:** replace `rastack serve`/`wasm` with `rastack dev` — full in-browser admin over WASM ([6f3f579](https://github.com/theserverkid/reactapistack/commit/6f3f579422624bd0dd3d834b6c0eff47eeef2214))
18
+
5
19
  ### [0.0.43](https://github.com/theserverkid/reactapistack/compare/v0.0.42...v0.0.43) (2026-07-12)
6
20
 
7
21
 
@@ -0,0 +1,461 @@
1
+ /**
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.
12
+ */
13
+ import React, { useEffect, useMemo, useState } from "react";
14
+ import {
15
+ AutoForm,
16
+ Button,
17
+ Card,
18
+ DataTable,
19
+ Spinner,
20
+ type DataTableColumnInput,
21
+ } from "../components";
22
+ import type { RastackEngine } from "../provider/types";
23
+ import {
24
+ AdminResource,
25
+ adminResources,
26
+ changelistHeaders,
27
+ findResource,
28
+ } from "./adapters";
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";
47
+
48
+ // -- hash routing (dependency-free) -----------------------------------------
49
+
50
+ type Route =
51
+ | { name: "index" }
52
+ | { name: "list"; app: string; model: string }
53
+ | { name: "add"; app: string; model: string }
54
+ | { name: "change"; app: string; model: string; id: string };
55
+
56
+ function parseRoute(hash: string): Route {
57
+ const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
58
+ if (parts.length < 2) return { name: "index" };
59
+ const [app, model, third] = parts;
60
+ if (!third) return { name: "list", app, model };
61
+ if (third === "add") return { name: "add", app, model };
62
+ return { name: "change", app, model, id: decodeURIComponent(third) };
63
+ }
64
+
65
+ function useRoute(): Route {
66
+ const [hash, setHash] = useState(() =>
67
+ typeof location !== "undefined" ? location.hash : "",
68
+ );
69
+ useEffect(() => {
70
+ const on = () => setHash(location.hash);
71
+ window.addEventListener("hashchange", on);
72
+ return () => window.removeEventListener("hashchange", on);
73
+ }, []);
74
+ return useMemo(() => parseRoute(hash), [hash]);
75
+ }
76
+
77
+ const go = (hash: string) => {
78
+ location.hash = hash;
79
+ };
80
+
81
+ // -- chrome -----------------------------------------------------------------
82
+
83
+ interface Crumb {
84
+ label: string;
85
+ hash?: string;
86
+ }
87
+
88
+ function Topbar({ crumbs }: { crumbs: Crumb[] }): React.JSX.Element {
89
+ return (
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}
95
+ {c.hash ? (
96
+ <a href={c.hash}>{c.label}</a>
97
+ ) : (
98
+ <span className="ras-crumb-current">{c.label}</span>
99
+ )}
100
+ </React.Fragment>
101
+ ))}
102
+ </nav>
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}
121
+ </div>
122
+ );
123
+ }
124
+
125
+ // -- index / overview -------------------------------------------------------
126
+
127
+ function IndexScreen(props: { resources: AdminResource[] }): React.JSX.Element {
128
+ const byApp = useMemo(() => {
129
+ const map = new Map<string, AdminResource[]>();
130
+ for (const r of adminResources(props.resources)) {
131
+ const list = map.get(r.app) ?? [];
132
+ list.push(r);
133
+ map.set(r.app, list);
134
+ }
135
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
136
+ }, [props.resources]);
137
+
138
+ return (
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
+ </>
178
+ );
179
+ }
180
+
181
+ // -- changelist -------------------------------------------------------------
182
+
183
+ /** Changelist columns: mono id link, FK chips, boolean dots — everything else default. */
184
+ function columnsFor(resource: AdminResource): DataTableColumnInput[] {
185
+ return changelistHeaders(resource).map((h) => {
186
+ if (h.key === "id") {
187
+ return {
188
+ key: "id",
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)}
196
+ </a>
197
+ ),
198
+ };
199
+ }
200
+ const field = resource.fields.find((f) => f.name === h.key);
201
+ if (field?.type === "fk" && field.relation) {
202
+ const { app, model } = field.relation;
203
+ return {
204
+ key: h.key,
205
+ header: h.name,
206
+ render: (_row: Record<string, unknown>, value: unknown) =>
207
+ value == null || value === "" ? (
208
+ <span className="ras-muted">—</span>
209
+ ) : (
210
+ <a
211
+ className="ras-fk"
212
+ href={`#/${app}/${model}/${String(value)}`}
213
+ onClick={(e) => e.stopPropagation()}
214
+ >
215
+ <IconLink size={12} />
216
+ {model} #{String(value)}
217
+ </a>
218
+ ),
219
+ };
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
+ }
238
+ return { key: h.key, header: h.name };
239
+ });
240
+ }
241
+
242
+ function ListScreen(props: {
243
+ engine: RastackEngine;
244
+ resource: AdminResource;
245
+ }): React.JSX.Element {
246
+ const { engine, resource } = props;
247
+ const table = useResourceTable(engine, resource);
248
+ const { fields: facets } = useFilterModel(engine, resource);
249
+ const columns = useMemo(() => columnsFor(resource), [resource.key]);
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>
264
+ </div>
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 }]}
275
+ />
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
+ </>
316
+ );
317
+ }
318
+
319
+ // -- add / change -----------------------------------------------------------
320
+
321
+ function FormScreen(props: {
322
+ engine: RastackEngine;
323
+ resource: AdminResource;
324
+ id?: string;
325
+ }): React.JSX.Element {
326
+ const { engine, resource, id } = props;
327
+ const { model, loading, loadError, submitError, done } = useResourceForm(
328
+ engine,
329
+ resource,
330
+ id,
331
+ );
332
+ const del = useDeleteRecord(engine, resource);
333
+ const listHash = `#/${resource.app}/${resource.model}/`;
334
+
335
+ useEffect(() => {
336
+ if (done) go(listHash);
337
+ }, [done]);
338
+
339
+ const onDelete = async () => {
340
+ if (!id) return;
341
+ if (typeof confirm === "function" && !confirm(`Delete this ${resource.label.toLowerCase()}?`))
342
+ return;
343
+ if (await del(id)) go(listHash);
344
+ };
345
+
346
+ return (
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
+ </>
400
+ );
401
+ }
402
+
403
+ // -- root -------------------------------------------------------------------
404
+
405
+ export function AdminApp(props: {
406
+ engine: RastackEngine;
407
+ resources: AdminResource[];
408
+ }): React.JSX.Element {
409
+ const route = useRoute();
410
+ const { engine, resources } = props;
411
+
412
+ const activeKey =
413
+ route.name === "index" ? undefined : `${route.app}.${route.model}`;
414
+
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
+ }
450
+ }
451
+
452
+ return (
453
+ <>
454
+ <AdminStyles />
455
+ <div className="ras-root">
456
+ <Sidebar resources={resources} activeKey={activeKey} />
457
+ <main className="ras-main">{screen}</main>
458
+ </div>
459
+ </>
460
+ );
461
+ }