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
@@ -0,0 +1,434 @@
1
+ /**
2
+ * React hooks that bridge the WASM engine to the reusable `rastack/components`
3
+ * shapes. `useResourceTable` synthesizes a `TableQuery` for `<DataTable>` from
4
+ * `engine.handle('GET', …)`; `useResourceForm` synthesizes an `AutoFormLike` for
5
+ * `<AutoForm>` (loading FK options + the edited row, wiring create/update). No
6
+ * generated per-app hooks — everything is driven by the manifest at runtime, so
7
+ * one admin serves any app.
8
+ */
9
+
10
+ import { useCallback, useEffect, useMemo, useState } from "react";
11
+ import type { RastackEngine } from "../provider/types";
12
+ import type { AutoFormField, AutoFormLike, TableQuery } from "../components/types";
13
+ import type {
14
+ IPaginationActions,
15
+ ISearchingActions,
16
+ } from "../hooks/query/interfaces";
17
+ import type { FieldOption } from "../hooks/form/interfaces";
18
+ import {
19
+ AdminResource,
20
+ FilterKind,
21
+ FilterOption,
22
+ PAGE_SIZE,
23
+ boolFilterOptions,
24
+ buildWriteBody,
25
+ changelistHeaders,
26
+ detailPath,
27
+ distinctValueOptions,
28
+ errorDetail,
29
+ fieldControlType,
30
+ filterableFields,
31
+ fkFilterOptions,
32
+ formFields,
33
+ initialFormValues,
34
+ listPath,
35
+ optionsFromRows,
36
+ pageCount,
37
+ readListEnvelope,
38
+ withFilters,
39
+ } from "./adapters";
40
+
41
+ interface EngineResult {
42
+ status: number;
43
+ body: any;
44
+ }
45
+ const ok = (r: EngineResult) => r.status >= 200 && r.status < 300;
46
+
47
+ /** What `useResourceTable` returns: a `TableQuery` plus changelist controls. */
48
+ export interface ResourceTable {
49
+ query: TableQuery;
50
+ reload: () => void;
51
+ /** Active `field → value` equality filters (the filter-bar chips). */
52
+ filters: Record<string, string>;
53
+ setFilter: (field: string, value: string) => void;
54
+ removeFilter: (field: string) => void;
55
+ clearFilters: () => void;
56
+ /** Current search text and its setter (rendered in the filter bar). */
57
+ search: string | undefined;
58
+ setSearch: (value: string | undefined) => void;
59
+ itemCount: number;
60
+ }
61
+
62
+ /** Drive a `<DataTable>` for one resource, backed by any `RastackEngine`. */
63
+ export function useResourceTable(
64
+ engine: RastackEngine,
65
+ resource: AdminResource,
66
+ ): ResourceTable {
67
+ const [page, setPage] = useState(1);
68
+ const [search, setSearchState] = useState<string | undefined>(undefined);
69
+ const [filters, setFilters] = useState<Record<string, string>>({});
70
+ const [data, setData] = useState<Record<string, unknown>[] | undefined>(
71
+ undefined,
72
+ );
73
+ const [count, setCount] = useState(0);
74
+ const [isLoading, setLoading] = useState(true);
75
+ const [error, setError] = useState<unknown>(undefined);
76
+ const [tick, setTick] = useState(0);
77
+
78
+ // Reset paging/search/filters when the resource changes.
79
+ useEffect(() => {
80
+ setPage(1);
81
+ setSearchState(undefined);
82
+ setFilters({});
83
+ }, [resource.key]);
84
+
85
+ const filterKey = JSON.stringify(filters);
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ setLoading(true);
89
+ setError(undefined);
90
+ const reserved: Record<string, string> = {
91
+ page: String(page),
92
+ page_size: String(PAGE_SIZE),
93
+ };
94
+ if (search) reserved.search = search;
95
+ engine
96
+ .handle(
97
+ "GET",
98
+ listPath(resource.app, resource.model),
99
+ withFilters(reserved, filters),
100
+ undefined,
101
+ )
102
+ .then((res: EngineResult) => {
103
+ if (cancelled) return;
104
+ if (ok(res)) {
105
+ const env = readListEnvelope(res.body);
106
+ setData(env.results);
107
+ setCount(env.count);
108
+ } else {
109
+ setError(errorDetail(res.body));
110
+ setData([]);
111
+ }
112
+ setLoading(false);
113
+ })
114
+ .catch((e: unknown) => {
115
+ if (cancelled) return;
116
+ setError(e);
117
+ setData([]);
118
+ setLoading(false);
119
+ });
120
+ return () => {
121
+ cancelled = true;
122
+ };
123
+ }, [engine, resource.key, resource.app, resource.model, page, search, filterKey, tick]);
124
+
125
+ const setSearch = (v: string | undefined) => {
126
+ setSearchState(v && v.length ? v : undefined);
127
+ setPage(1);
128
+ };
129
+ const setFilter = (field: string, value: string) => {
130
+ setFilters((f) => ({ ...f, [field]: value }));
131
+ setPage(1);
132
+ };
133
+ const removeFilter = (field: string) => {
134
+ setFilters((f) => {
135
+ const next = { ...f };
136
+ delete next[field];
137
+ return next;
138
+ });
139
+ setPage(1);
140
+ };
141
+ const clearFilters = () => {
142
+ setFilters({});
143
+ setPage(1);
144
+ };
145
+
146
+ const pagination: IPaginationActions = {
147
+ currentPage: page,
148
+ pageSize: PAGE_SIZE,
149
+ itemCount: count,
150
+ pageCount: pageCount(count, PAGE_SIZE),
151
+ nextPage: () =>
152
+ setPage((p) => Math.min(pageCount(count, PAGE_SIZE), p + 1)),
153
+ previousPage: () => setPage((p) => Math.max(1, p - 1)),
154
+ setPageSize: () => {
155
+ /* fixed page size, like the Rust admin */
156
+ },
157
+ };
158
+ const searching: ISearchingActions<Record<string, unknown>> = {
159
+ searchText: search,
160
+ setSearchText: (v) => setSearch(v),
161
+ };
162
+
163
+ const query: TableQuery = {
164
+ data,
165
+ isLoading,
166
+ isError: Boolean(error),
167
+ error,
168
+ header: changelistHeaders(resource),
169
+ pagination,
170
+ // No `sorting`: the store orders by id only, so sortable headers would lie.
171
+ searching: resource.search.length ? searching : undefined,
172
+ };
173
+ return {
174
+ query,
175
+ reload: () => setTick((t) => t + 1),
176
+ filters,
177
+ setFilter,
178
+ removeFilter,
179
+ clearFilters,
180
+ search,
181
+ setSearch,
182
+ itemCount: count,
183
+ };
184
+ }
185
+
186
+ /** A filter facet ready for the filter bar: its label, kind and value options. */
187
+ export interface FilterFieldModel {
188
+ name: string;
189
+ label: string;
190
+ kind: FilterKind;
191
+ options: FilterOption[];
192
+ }
193
+
194
+ /**
195
+ * Discover the changelist's filter facets and their value options from the
196
+ * engine: FK fields list their target table, `choice` (plain-string) fields are
197
+ * scanned for distinct values, booleans are True/False. Only facets that have
198
+ * at least one option are surfaced.
199
+ */
200
+ export function useFilterModel(
201
+ engine: RastackEngine,
202
+ resource: AdminResource,
203
+ ): { fields: FilterFieldModel[]; loading: boolean } {
204
+ const specs = useMemo(() => filterableFields(resource), [resource.key]);
205
+ const [fields, setFields] = useState<FilterFieldModel[]>([]);
206
+ const [loading, setLoading] = useState(specs.length > 0);
207
+
208
+ useEffect(() => {
209
+ let cancelled = false;
210
+ if (!specs.length) {
211
+ setFields([]);
212
+ setLoading(false);
213
+ return;
214
+ }
215
+ setLoading(true);
216
+ (async () => {
217
+ // Distinct-value facets need a scan of this table's rows (once).
218
+ let rows: Record<string, unknown>[] = [];
219
+ if (specs.some((s) => s.kind === "choice")) {
220
+ const res: EngineResult = await engine.handle(
221
+ "GET",
222
+ listPath(resource.app, resource.model),
223
+ { page_size: "1000" },
224
+ undefined,
225
+ );
226
+ rows = readListEnvelope(res.body).results;
227
+ }
228
+ const built = await Promise.all(
229
+ specs.map(async (s): Promise<FilterFieldModel> => {
230
+ let options: FilterOption[] = [];
231
+ if (s.kind === "bool") options = boolFilterOptions();
232
+ else if (s.kind === "choice") options = distinctValueOptions(rows, s.name);
233
+ else if (s.kind === "fk" && s.relation) {
234
+ const res: EngineResult = await engine.handle(
235
+ "GET",
236
+ listPath(s.relation.app, s.relation.model),
237
+ { page_size: "1000" },
238
+ undefined,
239
+ );
240
+ options = fkFilterOptions(readListEnvelope(res.body).results);
241
+ }
242
+ return { name: s.name, label: s.label, kind: s.kind, options };
243
+ }),
244
+ );
245
+ if (!cancelled) {
246
+ setFields(built.filter((f) => f.options.length > 0));
247
+ setLoading(false);
248
+ }
249
+ })().catch(() => {
250
+ if (!cancelled) {
251
+ setFields([]);
252
+ setLoading(false);
253
+ }
254
+ });
255
+ return () => {
256
+ cancelled = true;
257
+ };
258
+ }, [engine, resource.key]);
259
+
260
+ return { fields, loading };
261
+ }
262
+
263
+ /** State a form screen needs beyond the `AutoFormLike` itself. */
264
+ export interface FormState {
265
+ model: AutoFormLike;
266
+ loading: boolean;
267
+ loadError?: string;
268
+ submitError?: string;
269
+ done: boolean;
270
+ }
271
+
272
+ /** Drive an `<AutoForm>` for create (`id` undefined) or edit. */
273
+ export function useResourceForm(
274
+ engine: RastackEngine,
275
+ resource: AdminResource,
276
+ id?: string,
277
+ ): FormState {
278
+ const fields = useMemo(() => formFields(resource), [resource.key]);
279
+ const [values, setValues] = useState<Record<string, unknown>>(() =>
280
+ initialFormValues(resource),
281
+ );
282
+ const [errors] = useState<Record<string, string>>({});
283
+ const [fkOptions, setFkOptions] = useState<Record<string, FieldOption[]>>({});
284
+ const [submitting, setSubmitting] = useState(false);
285
+ const [loading, setLoading] = useState(Boolean(id));
286
+ const [loadError, setLoadError] = useState<string | undefined>();
287
+ const [submitError, setSubmitError] = useState<string | undefined>();
288
+ const [done, setDone] = useState(false);
289
+
290
+ // Reset when switching between add/edit or resources.
291
+ useEffect(() => {
292
+ setValues(initialFormValues(resource));
293
+ setDone(false);
294
+ setSubmitError(undefined);
295
+ setLoadError(undefined);
296
+ setLoading(Boolean(id));
297
+ }, [resource.key, id]);
298
+
299
+ // Load FK target options once per resource.
300
+ useEffect(() => {
301
+ let cancelled = false;
302
+ const fks = fields.filter((f) => f.type === "fk" && f.relation);
303
+ Promise.all(
304
+ fks.map(async (f) => {
305
+ const res: EngineResult = await engine.handle(
306
+ "GET",
307
+ listPath(f.relation!.app, f.relation!.model),
308
+ { page_size: "1000" },
309
+ undefined,
310
+ );
311
+ return [f.name, optionsFromRows(readListEnvelope(res.body).results)] as const;
312
+ }),
313
+ ).then((entries) => {
314
+ if (!cancelled) setFkOptions(Object.fromEntries(entries));
315
+ });
316
+ return () => {
317
+ cancelled = true;
318
+ };
319
+ }, [engine, resource.key]);
320
+
321
+ // Load the existing row when editing.
322
+ useEffect(() => {
323
+ if (!id) return;
324
+ let cancelled = false;
325
+ engine
326
+ .handle("GET", detailPath(resource.app, resource.model, id), undefined, undefined)
327
+ .then((res: EngineResult) => {
328
+ if (cancelled) return;
329
+ if (ok(res) && res.body && typeof res.body === "object") {
330
+ const row = res.body as Record<string, unknown>;
331
+ const next: Record<string, unknown> = {};
332
+ for (const f of fields) {
333
+ next[f.name] =
334
+ row[f.name] ?? (f.type === "bool" ? false : "");
335
+ }
336
+ setValues(next);
337
+ } else {
338
+ setLoadError(errorDetail(res.body));
339
+ }
340
+ setLoading(false);
341
+ })
342
+ .catch((e: unknown) => {
343
+ if (!cancelled) {
344
+ setLoadError(String(e));
345
+ setLoading(false);
346
+ }
347
+ });
348
+ return () => {
349
+ cancelled = true;
350
+ };
351
+ }, [engine, resource.key, id]);
352
+
353
+ const setValue = useCallback(
354
+ (name: string) => (v: unknown) =>
355
+ setValues((prev) => ({ ...prev, [name]: v })),
356
+ [],
357
+ );
358
+
359
+ const getFormField = useCallback(
360
+ (name: string): AutoFormField => {
361
+ const field = fields.find((f) => f.name === name)!;
362
+ const value = values[name];
363
+ return {
364
+ value,
365
+ setValue: setValue(name),
366
+ error: errors[name] ?? "",
367
+ type: fieldControlType(field),
368
+ isChecked: value === true,
369
+ isError: Boolean(errors[name]),
370
+ isDisabled: submitting,
371
+ isRequired: !field.nullable,
372
+ setTouched: () => {
373
+ /* touched tracked by AutoForm itself */
374
+ },
375
+ maxLength: field.maxLength,
376
+ options: field.type === "fk" ? fkOptions[field.name] ?? [] : undefined,
377
+ relation: field.relation,
378
+ nullable: field.nullable,
379
+ };
380
+ },
381
+ [fields, values, errors, submitting, fkOptions, setValue],
382
+ );
383
+
384
+ const onSubmit = useCallback(() => {
385
+ setSubmitting(true);
386
+ setSubmitError(undefined);
387
+ const body = buildWriteBody(resource, values);
388
+ const method = id ? "PUT" : "POST";
389
+ const path = id
390
+ ? detailPath(resource.app, resource.model, id)
391
+ : listPath(resource.app, resource.model);
392
+ engine
393
+ .handle(method, path, undefined, body)
394
+ .then((res: EngineResult) => {
395
+ setSubmitting(false);
396
+ if (ok(res)) setDone(true);
397
+ else setSubmitError(errorDetail(res.body));
398
+ })
399
+ .catch((e: unknown) => {
400
+ setSubmitting(false);
401
+ setSubmitError(String(e));
402
+ });
403
+ }, [engine, resource, values, id]);
404
+
405
+ const model: AutoFormLike = {
406
+ formValues: values,
407
+ getFormField,
408
+ onSubmit,
409
+ resetForm: () => setValues(initialFormValues(resource)),
410
+ isSubmitDisabled: submitting,
411
+ isSubmitting: submitting,
412
+ errors,
413
+ };
414
+ return { model, loading, loadError, submitError, done };
415
+ }
416
+
417
+ /** Delete a record via the engine; resolves true on success. */
418
+ export function useDeleteRecord(
419
+ engine: RastackEngine,
420
+ resource: AdminResource,
421
+ ): (id: string | number) => Promise<boolean> {
422
+ return useCallback(
423
+ async (id) => {
424
+ const res: EngineResult = await engine.handle(
425
+ "DELETE",
426
+ detailPath(resource.app, resource.model, id),
427
+ undefined,
428
+ undefined,
429
+ );
430
+ return ok(res);
431
+ },
432
+ [engine, resource.key],
433
+ );
434
+ }