@zerotal/admin 1.0.0
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 +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- package/src/widgets/render.tsx +154 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put a page inside the panel's chrome without touching the class that was
|
|
3
|
+
* handed over.
|
|
4
|
+
*
|
|
5
|
+
* A contributed page is owned by the package that wrote it, and that package may
|
|
6
|
+
* mount the same class somewhere else — its own standalone panel, say. Assigning
|
|
7
|
+
* `PageClass.layout = AdminLayout` would reach across and change it there too, so
|
|
8
|
+
* the panel hosts a *subclass* instead and leaves the original alone.
|
|
9
|
+
*
|
|
10
|
+
* The subclass keeps the original's name because Flow's component registry is
|
|
11
|
+
* keyed by constructor name: the browser sends that name back with every action
|
|
12
|
+
* frame, and an anonymous class would break the round-trip. Names being the key
|
|
13
|
+
* also means a single class can only be mounted under one route at a time — a
|
|
14
|
+
* page that wants to appear in two panels needs a distinct class per panel.
|
|
15
|
+
*
|
|
16
|
+
* Decorator metadata (`@expose`, `@url`, `@on`, …) is collected by walking the
|
|
17
|
+
* prototype chain, so it inherits into the subclass and interactivity survives.
|
|
18
|
+
*/
|
|
19
|
+
import { AdminLayout } from "../ui/AdminLayout.tsx";
|
|
20
|
+
import type { PanelPageClass } from "../plugin.ts";
|
|
21
|
+
|
|
22
|
+
export function hostedPage(PageClass: PanelPageClass): PanelPageClass {
|
|
23
|
+
if ((PageClass as { layout?: unknown }).layout === AdminLayout) return PageClass;
|
|
24
|
+
|
|
25
|
+
const Hosted = class extends PageClass {
|
|
26
|
+
static layout = AdminLayout;
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(Hosted, "name", { value: PageClass.name, configurable: true });
|
|
29
|
+
return Hosted;
|
|
30
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fluent table-column builder.
|
|
5
|
+
*
|
|
6
|
+
* text("name").label("Full name").sortable().searchable()
|
|
7
|
+
* text("status").badge((v) => v === "active" ? "success" : "muted")
|
|
8
|
+
* text("created_at").since()
|
|
9
|
+
*
|
|
10
|
+
* A `Column` is a declarative description; the list page turns it into a
|
|
11
|
+
* `@zerotal/flow-ui` `TableColumn` (with a cell renderer) at render time.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type CellAlign = "start" | "center" | "end";
|
|
15
|
+
export type BadgeTone = "default" | "primary" | "success" | "muted" | "destructive";
|
|
16
|
+
/** Display style: text, inline toggle/select/text-input, image, color swatch, boolean icon. */
|
|
17
|
+
export type ColumnKind = "text" | "toggle" | "select" | "input" | "image" | "color" | "icon";
|
|
18
|
+
|
|
19
|
+
export interface ColumnOption {
|
|
20
|
+
value: string;
|
|
21
|
+
label: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A column summary aggregate — a footer total, average, count or range. */
|
|
25
|
+
export type SummaryKind = "sum" | "avg" | "count" | "min" | "max" | "range";
|
|
26
|
+
|
|
27
|
+
export interface ColumnSummary {
|
|
28
|
+
kind: SummaryKind;
|
|
29
|
+
label?: string | undefined;
|
|
30
|
+
/** Format a numeric result (e.g. currency). */
|
|
31
|
+
format?: ((value: number) => string) | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A computed summary line, ready to render. */
|
|
35
|
+
export interface SummaryResult {
|
|
36
|
+
label: string;
|
|
37
|
+
text: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RenderableCell {
|
|
41
|
+
/** Pre-escaped/plain text, or a badge descriptor. */
|
|
42
|
+
text: string;
|
|
43
|
+
badge?: BadgeTone | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Column {
|
|
47
|
+
/** @internal */ _key: string;
|
|
48
|
+
/** @internal Database column for query ops (search/sort); defaults to `_key`. */
|
|
49
|
+
_column?: string;
|
|
50
|
+
/** @internal */ _label?: string;
|
|
51
|
+
/** @internal */ _kind: ColumnKind = "text";
|
|
52
|
+
/** @internal */ _sortable = false;
|
|
53
|
+
/** @internal */ _searchable = false;
|
|
54
|
+
/** @internal Offers a filter box in the table header. */ _filterable = false;
|
|
55
|
+
/** @internal */ _copyable = false;
|
|
56
|
+
/** @internal Included in CSV exports unless switched off. */ _exportable = true;
|
|
57
|
+
/** @internal */ _circular = false;
|
|
58
|
+
/** @internal */ _options?: ColumnOption[];
|
|
59
|
+
/** @internal */ _inputType = "text";
|
|
60
|
+
/** @internal */ _align: CellAlign = "start";
|
|
61
|
+
/** @internal */ _format?: (value: unknown, row: Record<string, unknown>) => string;
|
|
62
|
+
/** @internal A custom renderer, replacing every built-in cell kind. */
|
|
63
|
+
_render?: (value: unknown, row: Record<string, unknown>) => HtmlNode | string;
|
|
64
|
+
/** @internal */ _badge?: (value: unknown, row: Record<string, unknown>) => BadgeTone | null;
|
|
65
|
+
/** @internal */ _summaries: ColumnSummary[] = [];
|
|
66
|
+
|
|
67
|
+
constructor(key: string) {
|
|
68
|
+
this._key = key;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static make(key: string): Column {
|
|
72
|
+
return new Column(key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Human label for the header. Defaults to a title-cased key. */
|
|
76
|
+
label(label: string): this {
|
|
77
|
+
this._label = label;
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The database column to query for search / sort / inline-edit, when it differs
|
|
83
|
+
* from the cell key. Use when the model exposes a camelCase accessor over a
|
|
84
|
+
* snake_case column: `text("authorName").column("author_name")`. The cell still
|
|
85
|
+
* reads `row[key]` (the accessor); only the SQL uses this column.
|
|
86
|
+
*/
|
|
87
|
+
column(name: string): this {
|
|
88
|
+
this._column = name;
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The database column for query operations (defaults to the cell key). */
|
|
93
|
+
getColumn(): string {
|
|
94
|
+
return this._column ?? this._key;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Allow clicking the header to sort by this column (URL-driven). */
|
|
98
|
+
sortable(value = true): this {
|
|
99
|
+
this._sortable = value;
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Include this column in the list page's search. */
|
|
104
|
+
searchable(value = true): this {
|
|
105
|
+
this._searchable = value;
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Give this column its own filter box in the table header.
|
|
111
|
+
*
|
|
112
|
+
* The list page derives the control from the column's kind — a text box for
|
|
113
|
+
* text, a yes/no switch for a toggle, the declared choices for a select — so
|
|
114
|
+
* a column usually needs nothing beyond this call. Header filters write into
|
|
115
|
+
* the same `?filters=` parameter as declared filters and compose with tabs,
|
|
116
|
+
* search, sorting and pagination the same way.
|
|
117
|
+
*/
|
|
118
|
+
filterable(value = true): this {
|
|
119
|
+
this._filterable = value;
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Horizontal alignment of the cell content. */
|
|
124
|
+
align(align: CellAlign): this {
|
|
125
|
+
this._align = align;
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Custom value formatter (e.g. dates, currency). */
|
|
130
|
+
format(fn: (value: unknown, row: Record<string, unknown>) => string): this {
|
|
131
|
+
this._format = fn;
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Render the value as a colored badge; return `null` to fall back to text. */
|
|
136
|
+
badge(fn: (value: unknown, row: Record<string, unknown>) => BadgeTone | null): this {
|
|
137
|
+
this._badge = fn;
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Inline boolean toggle — flips the column on the record when clicked. */
|
|
142
|
+
toggle(): this {
|
|
143
|
+
this._kind = "toggle";
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Render the value as an image (avatar / thumbnail). */
|
|
148
|
+
image(): this {
|
|
149
|
+
this._kind = "image";
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Round image (avatar style). */
|
|
154
|
+
circular(value = true): this {
|
|
155
|
+
this._circular = value;
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Render the value as a color swatch + hex. */
|
|
160
|
+
color(): this {
|
|
161
|
+
this._kind = "color";
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Render a boolean as a check / cross icon. */
|
|
166
|
+
icon(): this {
|
|
167
|
+
this._kind = "icon";
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Inline single-choice select — saves the chosen value on change. */
|
|
172
|
+
editSelect(options: Record<string, string> | ColumnOption[]): this {
|
|
173
|
+
this._kind = "select";
|
|
174
|
+
this._options = Array.isArray(options)
|
|
175
|
+
? options
|
|
176
|
+
: Object.entries(options).map(([value, label]) => ({ value, label }));
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Inline text input — saves on change/blur. */
|
|
181
|
+
editText(type = "text"): this {
|
|
182
|
+
this._kind = "input";
|
|
183
|
+
this._inputType = type;
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Show a copy-to-clipboard affordance on the cell. */
|
|
188
|
+
copyable(value = true): this {
|
|
189
|
+
this._copyable = value;
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Summaries ───────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/** Attach one or more summary aggregates, shown in the table/group footer. */
|
|
196
|
+
summarize(summary: ColumnSummary | ColumnSummary[]): this {
|
|
197
|
+
this._summaries = Array.isArray(summary) ? summary : [summary];
|
|
198
|
+
return this;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Sum the column (shorthand for `summarize({ kind: "sum" })`). */
|
|
202
|
+
sum(label?: string, format?: (n: number) => string): this {
|
|
203
|
+
this._summaries.push({ kind: "sum", label, format });
|
|
204
|
+
return this;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Average the column. */
|
|
208
|
+
avg(label?: string, format?: (n: number) => string): this {
|
|
209
|
+
this._summaries.push({ kind: "avg", label, format });
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Count the rows. */
|
|
214
|
+
count(label?: string): this {
|
|
215
|
+
this._summaries.push({ kind: "count", label });
|
|
216
|
+
return this;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Min–max range of the column. */
|
|
220
|
+
range(label?: string, format?: (n: number) => string): this {
|
|
221
|
+
this._summaries.push({ kind: "range", label, format });
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether this column has any summary aggregates. */
|
|
226
|
+
hasSummary(): boolean {
|
|
227
|
+
return this._summaries.length > 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Compute this column's summaries over a set of rows. */
|
|
231
|
+
computeSummaries(rows: Record<string, unknown>[]): SummaryResult[] {
|
|
232
|
+
return this._summaries.map((s) => {
|
|
233
|
+
const nums = rows.map((r) => Number(r[this._key])).filter((n) => Number.isFinite(n));
|
|
234
|
+
const fmt = s.format ?? ((n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)));
|
|
235
|
+
switch (s.kind) {
|
|
236
|
+
case "count":
|
|
237
|
+
return { label: s.label ?? "Count", text: String(rows.length) };
|
|
238
|
+
case "sum":
|
|
239
|
+
return { label: s.label ?? "Sum", text: fmt(nums.reduce((a, b) => a + b, 0)) };
|
|
240
|
+
case "avg":
|
|
241
|
+
return {
|
|
242
|
+
label: s.label ?? "Average",
|
|
243
|
+
text: fmt(nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0),
|
|
244
|
+
};
|
|
245
|
+
case "min":
|
|
246
|
+
return { label: s.label ?? "Min", text: fmt(nums.length ? Math.min(...nums) : 0) };
|
|
247
|
+
case "max":
|
|
248
|
+
return { label: s.label ?? "Max", text: fmt(nums.length ? Math.max(...nums) : 0) };
|
|
249
|
+
case "range": {
|
|
250
|
+
const lo = nums.length ? Math.min(...nums) : 0;
|
|
251
|
+
const hi = nums.length ? Math.max(...nums) : 0;
|
|
252
|
+
return { label: s.label ?? "Range", text: `${fmt(lo)} – ${fmt(hi)}` };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Resolved header label. */
|
|
259
|
+
getLabel(): string {
|
|
260
|
+
return this._label ?? titleCase(this._key);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The raw cell value (for non-text column kinds the page renders itself). */
|
|
264
|
+
/**
|
|
265
|
+
* Whether this column may leave the panel in an export. Turn it off for
|
|
266
|
+
* anything that shouldn't land in a spreadsheet on someone's laptop.
|
|
267
|
+
*/
|
|
268
|
+
exportable(value = true): this {
|
|
269
|
+
this._exportable = value;
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Render this cell yourself, when no built-in kind fits — a sparkline, a
|
|
275
|
+
* progress bar, a stack of avatars.
|
|
276
|
+
*
|
|
277
|
+
* text("health").render((v) => <HealthBar value={Number(v)} />)
|
|
278
|
+
*
|
|
279
|
+
* The renderer replaces the cell entirely, so `.format()` and `.badge()` no
|
|
280
|
+
* longer apply. Everything else about the column — its label, whether it
|
|
281
|
+
* sorts, whether it exports — still works, because those are the table's
|
|
282
|
+
* concerns rather than the cell's.
|
|
283
|
+
*/
|
|
284
|
+
render(fn: (value: unknown, row: Record<string, unknown>) => HtmlNode | string): this {
|
|
285
|
+
this._render = fn;
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
raw(row: Record<string, unknown>): unknown {
|
|
290
|
+
return row[this._key];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Compute the display cell for a row. */
|
|
294
|
+
cell(row: Record<string, unknown>): RenderableCell {
|
|
295
|
+
const value = row[this._key];
|
|
296
|
+
const text = this._format ? this._format(value, row) : stringify(value);
|
|
297
|
+
const badge = this._badge ? (this._badge(value, row) ?? undefined) : undefined;
|
|
298
|
+
return { text, badge };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A text column — the one most columns are. */
|
|
303
|
+
export function text(key: string): Column {
|
|
304
|
+
return Column.make(key);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Toggle column, editable in place without opening the record. */
|
|
308
|
+
export function toggleColumn(key: string): Column {
|
|
309
|
+
return Column.make(key).toggle();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Image column — renders the value as a thumbnail. */
|
|
313
|
+
export function imageColumn(key: string): Column {
|
|
314
|
+
return Column.make(key).image();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Color-swatch column. */
|
|
318
|
+
export function colorColumn(key: string): Column {
|
|
319
|
+
return Column.make(key).color();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Boolean icon column — a tick or a cross rather than "true"/"false". */
|
|
323
|
+
export function iconColumn(key: string): Column {
|
|
324
|
+
return Column.make(key).icon();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Select column, editable in place without opening the record. */
|
|
328
|
+
export function selectColumn(
|
|
329
|
+
key: string,
|
|
330
|
+
options: Record<string, string> | ColumnOption[],
|
|
331
|
+
): Column {
|
|
332
|
+
return Column.make(key).editSelect(options);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Text column, editable in place without opening the record. */
|
|
336
|
+
export function textInputColumn(key: string): Column {
|
|
337
|
+
return Column.make(key).editText();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function stringify(value: unknown): string {
|
|
341
|
+
if (value === null || value === undefined) return "—";
|
|
342
|
+
if (value instanceof Date) return value.toISOString();
|
|
343
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
344
|
+
return String(value);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function titleCase(key: string): string {
|
|
348
|
+
return key
|
|
349
|
+
.replace(/[_-]+/g, " ")
|
|
350
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
351
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
352
|
+
.trim();
|
|
353
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constraints — the building blocks of the query-builder filter.
|
|
3
|
+
*
|
|
4
|
+
* A constraint names one thing a user may filter on and the operators that make
|
|
5
|
+
* sense for it, so the panel can offer "Title contains …" and "Total is greater
|
|
6
|
+
* than …" without the app writing either query:
|
|
7
|
+
*
|
|
8
|
+
* queryBuilder("q").constraints([
|
|
9
|
+
* textConstraint("title"),
|
|
10
|
+
* numberConstraint("total").label("Order total"),
|
|
11
|
+
* dateConstraint("created_at").label("Placed"),
|
|
12
|
+
* selectConstraint("status").options({ paid: "Paid", refunded: "Refunded" }),
|
|
13
|
+
* booleanConstraint("featured"),
|
|
14
|
+
* ])
|
|
15
|
+
*
|
|
16
|
+
* Each constraint knows how to turn a chosen operator and value into query
|
|
17
|
+
* predicates, and applies them with either `AND` or `OR` so a rule can sit in
|
|
18
|
+
* either kind of group.
|
|
19
|
+
*/
|
|
20
|
+
import type { AdminQuery } from "../Resource.ts";
|
|
21
|
+
|
|
22
|
+
export type ConstraintKind = "text" | "number" | "date" | "boolean" | "select";
|
|
23
|
+
|
|
24
|
+
/** One comparison a constraint offers. */
|
|
25
|
+
export interface ConstraintOperator {
|
|
26
|
+
value: string;
|
|
27
|
+
label: string;
|
|
28
|
+
/** True when the operator stands alone — "is empty" takes no value input. */
|
|
29
|
+
unary?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConstraintOption {
|
|
33
|
+
value: string;
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether a rule joins what came before it with `AND` or `OR`. */
|
|
38
|
+
export type Conjunction = "and" | "or";
|
|
39
|
+
|
|
40
|
+
const TEXT_OPERATORS: ConstraintOperator[] = [
|
|
41
|
+
{ value: "contains", label: "contains" },
|
|
42
|
+
{ value: "not_contains", label: "does not contain" },
|
|
43
|
+
{ value: "starts_with", label: "starts with" },
|
|
44
|
+
{ value: "ends_with", label: "ends with" },
|
|
45
|
+
{ value: "equals", label: "is" },
|
|
46
|
+
{ value: "not_equals", label: "is not" },
|
|
47
|
+
{ value: "is_empty", label: "is empty", unary: true },
|
|
48
|
+
{ value: "is_not_empty", label: "is not empty", unary: true },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const NUMBER_OPERATORS: ConstraintOperator[] = [
|
|
52
|
+
{ value: "equals", label: "is" },
|
|
53
|
+
{ value: "not_equals", label: "is not" },
|
|
54
|
+
{ value: "gt", label: "is greater than" },
|
|
55
|
+
{ value: "gte", label: "is at least" },
|
|
56
|
+
{ value: "lt", label: "is less than" },
|
|
57
|
+
{ value: "lte", label: "is at most" },
|
|
58
|
+
{ value: "is_empty", label: "is blank", unary: true },
|
|
59
|
+
{ value: "is_not_empty", label: "is set", unary: true },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const DATE_OPERATORS: ConstraintOperator[] = [
|
|
63
|
+
{ value: "equals", label: "is on" },
|
|
64
|
+
{ value: "lt", label: "is before" },
|
|
65
|
+
{ value: "gt", label: "is after" },
|
|
66
|
+
{ value: "is_empty", label: "is blank", unary: true },
|
|
67
|
+
{ value: "is_not_empty", label: "is set", unary: true },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const BOOLEAN_OPERATORS: ConstraintOperator[] = [
|
|
71
|
+
{ value: "is_true", label: "is true", unary: true },
|
|
72
|
+
{ value: "is_false", label: "is false", unary: true },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const SELECT_OPERATORS: ConstraintOperator[] = [
|
|
76
|
+
{ value: "equals", label: "is" },
|
|
77
|
+
{ value: "not_equals", label: "is not" },
|
|
78
|
+
{ value: "is_empty", label: "is blank", unary: true },
|
|
79
|
+
{ value: "is_not_empty", label: "is set", unary: true },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export class Constraint {
|
|
83
|
+
/** @internal */ _key: string;
|
|
84
|
+
/** @internal */ _kind: ConstraintKind;
|
|
85
|
+
/** @internal */ _label?: string;
|
|
86
|
+
/** @internal */ _column?: string;
|
|
87
|
+
/** @internal */ _options: ConstraintOption[] = [];
|
|
88
|
+
|
|
89
|
+
constructor(key: string, kind: ConstraintKind = "text") {
|
|
90
|
+
this._key = key;
|
|
91
|
+
this._kind = kind;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
label(label: string): this {
|
|
95
|
+
this._label = label;
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Database column to compare, when it differs from the constraint key. */
|
|
100
|
+
column(column: string): this {
|
|
101
|
+
this._column = column;
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Choices for a select constraint — `{value: label}` map or `{value,label}[]`. */
|
|
106
|
+
options(options: Record<string, string> | ConstraintOption[]): this {
|
|
107
|
+
this._options = Array.isArray(options)
|
|
108
|
+
? options
|
|
109
|
+
: Object.entries(options).map(([value, label]) => ({ value, label }));
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getLabel(): string {
|
|
114
|
+
return this._label ?? titleCase(this._key);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getColumn(): string {
|
|
118
|
+
return this._column ?? this._key;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
operators(): ConstraintOperator[] {
|
|
122
|
+
switch (this._kind) {
|
|
123
|
+
case "number":
|
|
124
|
+
return NUMBER_OPERATORS;
|
|
125
|
+
case "date":
|
|
126
|
+
return DATE_OPERATORS;
|
|
127
|
+
case "boolean":
|
|
128
|
+
return BOOLEAN_OPERATORS;
|
|
129
|
+
case "select":
|
|
130
|
+
return SELECT_OPERATORS;
|
|
131
|
+
default:
|
|
132
|
+
return TEXT_OPERATORS;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether `operator` stands alone, needing no value from the user. */
|
|
137
|
+
isUnary(operator: string): boolean {
|
|
138
|
+
return this.operators().find((o) => o.value === operator)?.unary === true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Add this rule's predicates to `query`, joined by `conjunction`.
|
|
143
|
+
*
|
|
144
|
+
* Every branch picks between the `where*` and `orWhere*` families rather than
|
|
145
|
+
* emitting a bare `OR`, so a rule always combines with its siblings as one
|
|
146
|
+
* unit and never splits a surrounding scope.
|
|
147
|
+
*/
|
|
148
|
+
apply(query: AdminQuery, operator: string, value: string, conjunction: Conjunction): AdminQuery {
|
|
149
|
+
const col = this.getColumn();
|
|
150
|
+
const or = conjunction === "or";
|
|
151
|
+
const q = query as AdminQuery &
|
|
152
|
+
Record<string, ((...args: unknown[]) => AdminQuery) | undefined>;
|
|
153
|
+
const call = (name: string, ...args: unknown[]): AdminQuery => {
|
|
154
|
+
const fn = q[name];
|
|
155
|
+
// The query surface is intentionally loose (resources run under partial
|
|
156
|
+
// mocks in tests); an unsupported method leaves the query unfiltered
|
|
157
|
+
// rather than throwing mid-render.
|
|
158
|
+
return typeof fn === "function" ? fn.apply(query, args) : query;
|
|
159
|
+
};
|
|
160
|
+
const like = (pattern: string): AdminQuery =>
|
|
161
|
+
call(or ? "orWhereLike" : "whereLike", col, pattern);
|
|
162
|
+
const compare = (op: string, v: unknown): AdminQuery =>
|
|
163
|
+
or ? call("orWhere", col, op, v) : query.where(col, op, v);
|
|
164
|
+
|
|
165
|
+
switch (operator) {
|
|
166
|
+
case "contains":
|
|
167
|
+
return like(`%${value}%`);
|
|
168
|
+
case "not_contains":
|
|
169
|
+
return call(or ? "orWhereNotLike" : "whereNotLike", col, `%${value}%`);
|
|
170
|
+
case "starts_with":
|
|
171
|
+
return like(`${value}%`);
|
|
172
|
+
case "ends_with":
|
|
173
|
+
return like(`%${value}`);
|
|
174
|
+
case "equals":
|
|
175
|
+
return compare("=", this._cast(value));
|
|
176
|
+
case "not_equals":
|
|
177
|
+
return compare("!=", this._cast(value));
|
|
178
|
+
case "gt":
|
|
179
|
+
return compare(">", this._cast(value));
|
|
180
|
+
case "gte":
|
|
181
|
+
return compare(">=", this._cast(value));
|
|
182
|
+
case "lt":
|
|
183
|
+
return compare("<", this._cast(value));
|
|
184
|
+
case "lte":
|
|
185
|
+
return compare("<=", this._cast(value));
|
|
186
|
+
case "is_true":
|
|
187
|
+
return compare("=", true);
|
|
188
|
+
case "is_false":
|
|
189
|
+
return compare("=", false);
|
|
190
|
+
case "is_empty":
|
|
191
|
+
return call(or ? "orWhereNull" : "whereNull", col);
|
|
192
|
+
case "is_not_empty":
|
|
193
|
+
return call(or ? "orWhereNotNull" : "whereNotNull", col);
|
|
194
|
+
default:
|
|
195
|
+
return query;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Coerce the submitted string to the type the column actually holds. */
|
|
200
|
+
private _cast(value: string): unknown {
|
|
201
|
+
if (this._kind !== "number") return value;
|
|
202
|
+
const n = Number(value);
|
|
203
|
+
return Number.isFinite(n) ? n : value;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Free-text constraint — contains / starts with / is / is empty. */
|
|
208
|
+
export function textConstraint(key: string): Constraint {
|
|
209
|
+
return new Constraint(key, "text");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Numeric constraint — comparisons and ranges. */
|
|
213
|
+
export function numberConstraint(key: string): Constraint {
|
|
214
|
+
return new Constraint(key, "number");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Date constraint — on / before / after. */
|
|
218
|
+
export function dateConstraint(key: string): Constraint {
|
|
219
|
+
return new Constraint(key, "date");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Boolean constraint — is true / is false. */
|
|
223
|
+
export function booleanConstraint(key: string): Constraint {
|
|
224
|
+
return new Constraint(key, "boolean");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Fixed-choice constraint — is / is not one of the declared options. */
|
|
228
|
+
export function selectConstraint(key: string): Constraint {
|
|
229
|
+
return new Constraint(key, "select");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function titleCase(key: string): string {
|
|
233
|
+
return key
|
|
234
|
+
.replace(/[_-]+/g, " ")
|
|
235
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
236
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
237
|
+
.trim();
|
|
238
|
+
}
|