@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,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infolist entries — the read-only counterpart to form fields. An entry
|
|
3
|
+
* describes how to pull a value off a record and present it (badge, icon,
|
|
4
|
+
* copyable, date, money, image, colour swatch, …). The View page turns each
|
|
5
|
+
* entry into markup via {@link Entry.display}.
|
|
6
|
+
*
|
|
7
|
+
* textEntry("email").icon("mail").copyable()
|
|
8
|
+
* textEntry("created_at").label("Joined").dateTime()
|
|
9
|
+
* textEntry("role").badge().color((v) => (v === "admin" ? "primary" : "muted"))
|
|
10
|
+
* iconEntry("email_verified_at").label("Verified").boolean()
|
|
11
|
+
* imageEntry("avatar_url").circular()
|
|
12
|
+
* colorEntry("brand_color")
|
|
13
|
+
* codeEntry("payload").language("json")
|
|
14
|
+
* keyValueEntry("meta")
|
|
15
|
+
*/
|
|
16
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
17
|
+
import type { BadgeTone } from "../table/Column.ts";
|
|
18
|
+
|
|
19
|
+
export type EntryWeight = "normal" | "medium" | "semibold" | "bold";
|
|
20
|
+
export type EntrySize = "sm" | "base" | "lg";
|
|
21
|
+
export type EntryKind = "text" | "icon" | "image" | "color" | "code" | "keyValue" | "repeatable";
|
|
22
|
+
|
|
23
|
+
type Resolver<T> = (value: unknown, row: Record<string, unknown>) => T;
|
|
24
|
+
|
|
25
|
+
/** A fully-resolved entry, ready for the View page to render. */
|
|
26
|
+
export interface EntryDisplay {
|
|
27
|
+
kind: EntryKind;
|
|
28
|
+
label: string;
|
|
29
|
+
/** Display text (already formatted/truncated). */
|
|
30
|
+
text: string;
|
|
31
|
+
/** True when the value was empty and the placeholder is shown. */
|
|
32
|
+
isPlaceholder: boolean;
|
|
33
|
+
badge: boolean;
|
|
34
|
+
tone: BadgeTone | null;
|
|
35
|
+
icon: string | null;
|
|
36
|
+
/** When set, render an icon-only boolean state (IconEntry). */
|
|
37
|
+
boolean: boolean | null;
|
|
38
|
+
copyValue: string | null;
|
|
39
|
+
href: string | null;
|
|
40
|
+
html: boolean;
|
|
41
|
+
weight: EntryWeight;
|
|
42
|
+
size: EntrySize;
|
|
43
|
+
tooltip: string | null;
|
|
44
|
+
columnSpan: number;
|
|
45
|
+
/** Render an image as a circle (avatars) rather than a rounded rectangle. */
|
|
46
|
+
circular: boolean;
|
|
47
|
+
/** Pixel height for an image entry. */
|
|
48
|
+
imageHeight: number;
|
|
49
|
+
/** Syntax hint shown beside a code entry. */
|
|
50
|
+
language: string | null;
|
|
51
|
+
/** Key/value pairs, for a keyValue entry. */
|
|
52
|
+
pairs: { key: string; value: string }[];
|
|
53
|
+
/** One nested entry set per item, for a repeatable entry. */
|
|
54
|
+
items: EntryDisplay[][];
|
|
55
|
+
/** A custom renderer supplied with `.render()`, when one was. */
|
|
56
|
+
custom: ((value: unknown, row: Record<string, unknown>) => HtmlNode | string) | null;
|
|
57
|
+
/** The raw value and row, so a custom renderer receives what it expects. */
|
|
58
|
+
raw: unknown;
|
|
59
|
+
row: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class Entry {
|
|
63
|
+
/** @internal */ _key: string;
|
|
64
|
+
/** @internal */ _kind: EntryKind = "text";
|
|
65
|
+
/** @internal */ _label?: string;
|
|
66
|
+
/** @internal */ _state?: (row: Record<string, unknown>) => unknown;
|
|
67
|
+
/** @internal */ _default?: unknown;
|
|
68
|
+
/** @internal */ _placeholder = "—";
|
|
69
|
+
/** @internal */ _format?: Resolver<string>;
|
|
70
|
+
/** @internal */ _badge = false;
|
|
71
|
+
/** @internal */ _color?: BadgeTone | Resolver<BadgeTone | null>;
|
|
72
|
+
/** @internal */ _icon?: string | Resolver<string | null>;
|
|
73
|
+
/** @internal */ _boolean = false;
|
|
74
|
+
/** @internal */ _copyable = false;
|
|
75
|
+
/** @internal */ _url?: Resolver<string | null>;
|
|
76
|
+
/** @internal */ _html = false;
|
|
77
|
+
/** @internal */ _weight: EntryWeight = "normal";
|
|
78
|
+
/** @internal */ _size: EntrySize = "base";
|
|
79
|
+
/** @internal */ _tooltip?: string;
|
|
80
|
+
/** @internal */ _columnSpan = 1;
|
|
81
|
+
/** @internal */ _limit?: number;
|
|
82
|
+
/** @internal */ _date?: "date" | "datetime" | "since";
|
|
83
|
+
/** @internal */ _money?: string;
|
|
84
|
+
/** @internal */ _circular = false;
|
|
85
|
+
/** @internal */ _imageHeight = 48;
|
|
86
|
+
/** @internal */ _language?: string;
|
|
87
|
+
/** @internal Nested schema, for a repeatable entry. */ _schema: Entry[] = [];
|
|
88
|
+
/** @internal A custom renderer, replacing every built-in entry kind. */
|
|
89
|
+
_render?: (value: unknown, row: Record<string, unknown>) => HtmlNode | string;
|
|
90
|
+
|
|
91
|
+
constructor(key: string) {
|
|
92
|
+
this._key = key;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static make(key: string): Entry {
|
|
96
|
+
return new Entry(key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Value resolution ───────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** Human label for the entry. Defaults to a title-cased key. */
|
|
102
|
+
label(label: string): this {
|
|
103
|
+
this._label = label;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Derive the value from the whole record instead of `record[key]`. */
|
|
108
|
+
state(fn: (row: Record<string, unknown>) => unknown): this {
|
|
109
|
+
this._state = fn;
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Value used when the record's value is null/undefined. */
|
|
114
|
+
default(value: unknown): this {
|
|
115
|
+
this._default = value;
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Text shown when the resolved value is empty. */
|
|
120
|
+
placeholder(text: string): this {
|
|
121
|
+
this._placeholder = text;
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Custom value formatter (runs last in the pipeline). */
|
|
126
|
+
format(fn: Resolver<string>): this {
|
|
127
|
+
this._format = fn;
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Presentation ───────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** Render the value inside a colored pill. */
|
|
134
|
+
badge(value = true): this {
|
|
135
|
+
this._badge = value;
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Badge/text tone — a fixed tone or a function of the value. */
|
|
140
|
+
color(tone: BadgeTone | Resolver<BadgeTone | null>): this {
|
|
141
|
+
this._color = tone;
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Leading icon (key from `ui/icons`), fixed or computed. */
|
|
146
|
+
icon(name: string | Resolver<string | null>): this {
|
|
147
|
+
this._icon = name;
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Render as a boolean check/cross icon (IconEntry). */
|
|
152
|
+
boolean(value = true): this {
|
|
153
|
+
this._boolean = value;
|
|
154
|
+
this._kind = "icon";
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Add a click-to-copy button next to the value. */
|
|
159
|
+
copyable(value = true): this {
|
|
160
|
+
this._copyable = value;
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Turn the value into a link. */
|
|
165
|
+
url(fn: Resolver<string | null>): this {
|
|
166
|
+
this._url = fn;
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Treat the formatted value as trusted HTML (rendered as-is). */
|
|
171
|
+
html(value = true): this {
|
|
172
|
+
this._html = value;
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Font weight. */
|
|
177
|
+
weight(weight: EntryWeight): this {
|
|
178
|
+
this._weight = weight;
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Text size. */
|
|
183
|
+
size(size: EntrySize): this {
|
|
184
|
+
this._size = size;
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Hover tooltip. */
|
|
189
|
+
tooltip(text: string): this {
|
|
190
|
+
this._tooltip = text;
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** How many grid columns this entry spans within its section. */
|
|
195
|
+
columnSpan(span: number): this {
|
|
196
|
+
this._columnSpan = Math.max(1, span);
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Truncate the formatted value to `n` characters (adds an ellipsis). */
|
|
201
|
+
limit(n: number): this {
|
|
202
|
+
this._limit = n;
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Value casts ──────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/** Format the value as a localized date. */
|
|
209
|
+
date(): this {
|
|
210
|
+
this._date = "date";
|
|
211
|
+
return this;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Format the value as a localized date + time. */
|
|
215
|
+
dateTime(): this {
|
|
216
|
+
this._date = "datetime";
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Format the value as a relative time ("3 days ago"). */
|
|
221
|
+
since(): this {
|
|
222
|
+
this._date = "since";
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Format the value as currency. */
|
|
227
|
+
money(currency = "USD"): this {
|
|
228
|
+
this._money = currency;
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Resolution ─────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
getLabel(): string {
|
|
235
|
+
return this._label ?? titleCase(this._key);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private _rawState(row: Record<string, unknown>): unknown {
|
|
239
|
+
const raw = this._state ? this._state(row) : getByPath(row, this._key);
|
|
240
|
+
return raw === null || raw === undefined || raw === "" ? (this._default ?? null) : raw;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Compute everything the page needs to render this entry for `row`. */
|
|
244
|
+
/** Render an image entry as a circle — avatars and logos. */
|
|
245
|
+
circular(value = true): this {
|
|
246
|
+
this._circular = value;
|
|
247
|
+
return this;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Pixel height for an image entry (width follows the aspect ratio). */
|
|
251
|
+
height(px: number): this {
|
|
252
|
+
this._imageHeight = px;
|
|
253
|
+
return this;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Syntax label shown beside a code entry — `"json"`, `"sql"`, `"ts"`. */
|
|
257
|
+
language(name: string): this {
|
|
258
|
+
this._language = name;
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render this entry yourself, when no built-in kind fits — a map, a chart, a
|
|
264
|
+
* rendered diff.
|
|
265
|
+
*
|
|
266
|
+
* textEntry("route").render((v) => <RouteMap path={String(v)} />)
|
|
267
|
+
*
|
|
268
|
+
* The renderer replaces the value entirely; the label and column span still
|
|
269
|
+
* come from the entry, because those belong to the section's grid rather than
|
|
270
|
+
* to the value.
|
|
271
|
+
*/
|
|
272
|
+
render(fn: (value: unknown, row: Record<string, unknown>) => HtmlNode | string): this {
|
|
273
|
+
this._render = fn;
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Nested entries rendered once per item of an array-valued attribute. */
|
|
278
|
+
schema(entries: Entry[]): this {
|
|
279
|
+
this._schema = entries;
|
|
280
|
+
return this;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
display(row: Record<string, unknown>): EntryDisplay {
|
|
284
|
+
const value = this._rawState(row);
|
|
285
|
+
const tone =
|
|
286
|
+
typeof this._color === "function" ? this._color(value, row) : (this._color ?? null);
|
|
287
|
+
const icon = typeof this._icon === "function" ? this._icon(value, row) : (this._icon ?? null);
|
|
288
|
+
const href = this._url ? this._url(value, row) : null;
|
|
289
|
+
|
|
290
|
+
// A repeatable entry has no scalar value of its own — it renders its nested
|
|
291
|
+
// schema once per item, so an empty array shows the placeholder instead.
|
|
292
|
+
if (this._kind === "repeatable") {
|
|
293
|
+
const list = Array.isArray(value) ? value : [];
|
|
294
|
+
return {
|
|
295
|
+
...this._base(row, value),
|
|
296
|
+
kind: "repeatable",
|
|
297
|
+
text: list.length === 0 ? this._placeholder : "",
|
|
298
|
+
isPlaceholder: list.length === 0,
|
|
299
|
+
items: list.map((item) =>
|
|
300
|
+
this._schema.map((entry) => entry.display((item ?? {}) as Record<string, unknown>)),
|
|
301
|
+
),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (this._kind === "keyValue") {
|
|
306
|
+
const pairs = toPairs(value);
|
|
307
|
+
return {
|
|
308
|
+
...this._base(row, value),
|
|
309
|
+
kind: "keyValue",
|
|
310
|
+
text: pairs.length === 0 ? this._placeholder : "",
|
|
311
|
+
isPlaceholder: pairs.length === 0,
|
|
312
|
+
pairs,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (this._boolean) {
|
|
317
|
+
const truthy =
|
|
318
|
+
value !== null && value !== undefined && value !== false && value !== 0 && value !== "";
|
|
319
|
+
return {
|
|
320
|
+
...this._base(row, value),
|
|
321
|
+
kind: "icon",
|
|
322
|
+
text: truthy ? "Yes" : "No",
|
|
323
|
+
tone: tone ?? (truthy ? "success" : "muted"),
|
|
324
|
+
boolean: truthy,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const isEmpty = value === null || value === undefined || value === "";
|
|
329
|
+
const text = isEmpty ? this._placeholder : this._formatValue(value, row);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
...this._base(row, value),
|
|
333
|
+
// Image, colour and code entries carry their kind through so the View page
|
|
334
|
+
// can pick a renderer; the resolved text is the URL, hex or source.
|
|
335
|
+
kind: this._kind === "icon" ? "text" : this._kind,
|
|
336
|
+
text,
|
|
337
|
+
isPlaceholder: isEmpty,
|
|
338
|
+
badge: this._badge && !isEmpty,
|
|
339
|
+
tone,
|
|
340
|
+
icon,
|
|
341
|
+
copyValue: this._copyable && !isEmpty ? String(value) : null,
|
|
342
|
+
href: isEmpty ? null : href,
|
|
343
|
+
html: this._html && !isEmpty,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The fields every kind shares, so each branch states only its differences. */
|
|
348
|
+
private _base(row: Record<string, unknown> = {}, raw: unknown = undefined): EntryDisplay {
|
|
349
|
+
return {
|
|
350
|
+
kind: "text",
|
|
351
|
+
label: this.getLabel(),
|
|
352
|
+
text: "",
|
|
353
|
+
isPlaceholder: false,
|
|
354
|
+
badge: false,
|
|
355
|
+
tone: null,
|
|
356
|
+
icon: null,
|
|
357
|
+
boolean: null,
|
|
358
|
+
copyValue: null,
|
|
359
|
+
href: null,
|
|
360
|
+
html: false,
|
|
361
|
+
weight: this._weight,
|
|
362
|
+
size: this._size,
|
|
363
|
+
tooltip: this._tooltip ?? null,
|
|
364
|
+
columnSpan: this._columnSpan,
|
|
365
|
+
circular: this._circular,
|
|
366
|
+
imageHeight: this._imageHeight,
|
|
367
|
+
language: this._language ?? null,
|
|
368
|
+
pairs: [],
|
|
369
|
+
items: [],
|
|
370
|
+
custom: this._render ?? null,
|
|
371
|
+
raw,
|
|
372
|
+
row,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private _formatValue(value: unknown, row: Record<string, unknown>): string {
|
|
377
|
+
if (this._format) return this._format(value, row);
|
|
378
|
+
let out: string;
|
|
379
|
+
if (this._money) out = formatMoney(value, this._money);
|
|
380
|
+
else if (this._date) out = formatDate(value, this._date);
|
|
381
|
+
else out = stringify(value);
|
|
382
|
+
if (this._limit && out.length > this._limit) out = out.slice(0, this._limit).trimEnd() + "…";
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Plain value — the default entry. */
|
|
388
|
+
export function textEntry(key: string): Entry {
|
|
389
|
+
return Entry.make(key);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A truthy/falsy value shown as a check or a cross. */
|
|
393
|
+
export function iconEntry(key: string): Entry {
|
|
394
|
+
return Entry.make(key).boolean();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** An image, from a URL held on the record. */
|
|
398
|
+
export function imageEntry(key: string): Entry {
|
|
399
|
+
const entry = Entry.make(key);
|
|
400
|
+
entry._kind = "image";
|
|
401
|
+
return entry;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** A colour swatch beside its value — `#4f46e5`, `rgb(...)`. */
|
|
405
|
+
export function colorEntry(key: string): Entry {
|
|
406
|
+
const entry = Entry.make(key);
|
|
407
|
+
entry._kind = "color";
|
|
408
|
+
return entry;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Source or serialised data, in a monospace block. */
|
|
412
|
+
export function codeEntry(key: string): Entry {
|
|
413
|
+
const entry = Entry.make(key);
|
|
414
|
+
entry._kind = "code";
|
|
415
|
+
return entry;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** An object or map, as a two-column table of its pairs. */
|
|
419
|
+
export function keyValueEntry(key: string): Entry {
|
|
420
|
+
const entry = Entry.make(key);
|
|
421
|
+
entry._kind = "keyValue";
|
|
422
|
+
return entry;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* An array of objects, rendering the nested {@link Entry.schema} once per item —
|
|
427
|
+
* order line items, project milestones, anything repeated.
|
|
428
|
+
*/
|
|
429
|
+
export function repeatableEntry(key: string): Entry {
|
|
430
|
+
const entry = Entry.make(key);
|
|
431
|
+
entry._kind = "repeatable";
|
|
432
|
+
return entry;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Normalise an object (or a `{key,value}[]`) into displayable pairs. */
|
|
436
|
+
function toPairs(value: unknown): { key: string; value: string }[] {
|
|
437
|
+
if (Array.isArray(value)) {
|
|
438
|
+
return value
|
|
439
|
+
.filter((v): v is Record<string, unknown> => Boolean(v) && typeof v === "object")
|
|
440
|
+
.map((v) => ({ key: String(v["key"] ?? ""), value: stringify(v["value"]) }));
|
|
441
|
+
}
|
|
442
|
+
if (value && typeof value === "object") {
|
|
443
|
+
return Object.entries(value as Record<string, unknown>).map(([key, v]) => ({
|
|
444
|
+
key,
|
|
445
|
+
value: stringify(v),
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Formatting helpers ───────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
function getByPath(row: Record<string, unknown>, path: string): unknown {
|
|
454
|
+
if (path in row) return row[path];
|
|
455
|
+
return path
|
|
456
|
+
.split(".")
|
|
457
|
+
.reduce<unknown>((acc, k) => (acc == null ? acc : (acc as Record<string, unknown>)[k]), row);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function toDate(value: unknown): Date | null {
|
|
461
|
+
if (value instanceof Date) return value;
|
|
462
|
+
// Carbon (and other Date wrappers) expose toDate(); use it without importing Carbon.
|
|
463
|
+
if (value && typeof (value as { toDate?: unknown }).toDate === "function") {
|
|
464
|
+
const d = (value as { toDate(): Date }).toDate();
|
|
465
|
+
return d instanceof Date && !Number.isNaN(d.getTime()) ? d : null;
|
|
466
|
+
}
|
|
467
|
+
if (typeof value === "number") return new Date(value);
|
|
468
|
+
if (typeof value === "string") {
|
|
469
|
+
const d = new Date(value);
|
|
470
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function formatDate(value: unknown, mode: "date" | "datetime" | "since"): string {
|
|
476
|
+
const d = toDate(value);
|
|
477
|
+
if (!d) return stringify(value);
|
|
478
|
+
if (mode === "since") return formatSince(d);
|
|
479
|
+
return mode === "datetime" ? d.toLocaleString() : d.toLocaleDateString();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function formatSince(d: Date): string {
|
|
483
|
+
const secs = Math.round((Date.now() - d.getTime()) / 1000);
|
|
484
|
+
const future = secs < 0;
|
|
485
|
+
const abs = Math.abs(secs);
|
|
486
|
+
const units: [number, string][] = [
|
|
487
|
+
[60, "second"],
|
|
488
|
+
[3600, "minute"],
|
|
489
|
+
[86400, "hour"],
|
|
490
|
+
[2592000, "day"],
|
|
491
|
+
[31536000, "month"],
|
|
492
|
+
[Infinity, "year"],
|
|
493
|
+
];
|
|
494
|
+
const divisors = [1, 60, 3600, 86400, 2592000, 31536000];
|
|
495
|
+
for (let i = 0; i < units.length; i++) {
|
|
496
|
+
if (abs < units[i]![0]) {
|
|
497
|
+
const n = Math.max(1, Math.floor(abs / divisors[i]!));
|
|
498
|
+
const unit = n === 1 ? units[i]![1] : units[i]![1] + "s";
|
|
499
|
+
return future ? `in ${n} ${unit}` : `${n} ${unit} ago`;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return d.toLocaleDateString();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function formatMoney(value: unknown, currency: string): string {
|
|
506
|
+
const n = typeof value === "number" ? value : parseFloat(String(value));
|
|
507
|
+
if (Number.isNaN(n)) return stringify(value);
|
|
508
|
+
try {
|
|
509
|
+
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(n);
|
|
510
|
+
} catch {
|
|
511
|
+
return `${currency} ${n.toFixed(2)}`;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function stringify(value: unknown): string {
|
|
516
|
+
if (value === null || value === undefined) return "";
|
|
517
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
518
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
519
|
+
// Carbon (and similar) — prefer a readable form over JSON.
|
|
520
|
+
if (typeof value === "object") {
|
|
521
|
+
const v = value as { toDate?: unknown; toString?: unknown };
|
|
522
|
+
if (typeof v.toDate === "function") return (v.toDate() as Date).toLocaleString();
|
|
523
|
+
if (typeof v.toString === "function" && v.toString !== Object.prototype.toString) {
|
|
524
|
+
return String(value);
|
|
525
|
+
}
|
|
526
|
+
return JSON.stringify(value);
|
|
527
|
+
}
|
|
528
|
+
return String(value);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function titleCase(key: string): string {
|
|
532
|
+
return key
|
|
533
|
+
.replace(/[_-]+/g, " ")
|
|
534
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
535
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
536
|
+
.trim();
|
|
537
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section — an infolist layout component grouping entries under an optional
|
|
3
|
+
* heading/description in a responsive grid.
|
|
4
|
+
*
|
|
5
|
+
* section("Account")
|
|
6
|
+
* .description("Profile and sign-in details")
|
|
7
|
+
* .columns(2)
|
|
8
|
+
* .schema([
|
|
9
|
+
* textEntry("name"),
|
|
10
|
+
* textEntry("email").copyable(),
|
|
11
|
+
* ])
|
|
12
|
+
*/
|
|
13
|
+
import { Entry } from "./Entry.ts";
|
|
14
|
+
|
|
15
|
+
export class Section {
|
|
16
|
+
/** @internal */ _heading?: string | undefined;
|
|
17
|
+
/** @internal */ _description?: string;
|
|
18
|
+
/** @internal */ _icon?: string;
|
|
19
|
+
/** @internal */ _columns = 1;
|
|
20
|
+
/** @internal */ _collapsible = false;
|
|
21
|
+
/** @internal */ _collapsed = false;
|
|
22
|
+
/** @internal */ _entries: Entry[] = [];
|
|
23
|
+
|
|
24
|
+
constructor(heading?: string) {
|
|
25
|
+
this._heading = heading;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static make(heading?: string): Section {
|
|
29
|
+
return new Section(heading);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
heading(heading: string): this {
|
|
33
|
+
this._heading = heading;
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
description(description: string): this {
|
|
38
|
+
this._description = description;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Leading icon for the section heading (key from `ui/icons`). */
|
|
43
|
+
icon(name: string): this {
|
|
44
|
+
this._icon = name;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Number of grid columns the entries flow into. */
|
|
49
|
+
columns(n: number): this {
|
|
50
|
+
this._columns = Math.max(1, n);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Allow the section to be collapsed (optionally collapsed by default). */
|
|
55
|
+
collapsible(collapsed = false): this {
|
|
56
|
+
this._collapsible = true;
|
|
57
|
+
this._collapsed = collapsed;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The section's entries. */
|
|
62
|
+
schema(entries: Entry[]): this {
|
|
63
|
+
this._entries = entries;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An infolist is an ordered list of sections and/or loose entries. */
|
|
69
|
+
export type InfolistComponent = Section | Entry;
|
|
70
|
+
|
|
71
|
+
/** A titled block of read-only entries on the View page. */
|
|
72
|
+
export function section(heading?: string): Section {
|
|
73
|
+
return new Section(heading);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalize an infolist schema into sections: loose top-level entries are
|
|
78
|
+
* wrapped in a single untitled section so the View page only renders sections.
|
|
79
|
+
*/
|
|
80
|
+
export function toSections(components: InfolistComponent[]): Section[] {
|
|
81
|
+
const out: Section[] = [];
|
|
82
|
+
let loose: Entry[] = [];
|
|
83
|
+
const flush = (): void => {
|
|
84
|
+
if (loose.length) {
|
|
85
|
+
out.push(new Section().schema(loose));
|
|
86
|
+
loose = [];
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
for (const c of components) {
|
|
90
|
+
if (c instanceof Section) {
|
|
91
|
+
flush();
|
|
92
|
+
out.push(c);
|
|
93
|
+
} else {
|
|
94
|
+
loose.push(c);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
flush();
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Infolist building blocks — read-only schemas for the View page. */
|
|
2
|
+
export {
|
|
3
|
+
Entry,
|
|
4
|
+
textEntry,
|
|
5
|
+
iconEntry,
|
|
6
|
+
imageEntry,
|
|
7
|
+
colorEntry,
|
|
8
|
+
codeEntry,
|
|
9
|
+
keyValueEntry,
|
|
10
|
+
repeatableEntry,
|
|
11
|
+
} from "./Entry.ts";
|
|
12
|
+
export type { EntryDisplay, EntryKind, EntrySize, EntryWeight } from "./Entry.ts";
|
|
13
|
+
export { Section, section, toSections } from "./Section.ts";
|
|
14
|
+
export type { InfolistComponent } from "./Section.ts";
|
|
15
|
+
|
|
16
|
+
import { Entry } from "./Entry.ts";
|
|
17
|
+
import { Section, type InfolistComponent, toSections } from "./Section.ts";
|
|
18
|
+
import type { Column } from "../table/Column.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a resource's View schema as sections. If `infolist()` returns
|
|
22
|
+
* components, use them; otherwise derive a single section from the table
|
|
23
|
+
* `columns()` (carrying their labels, formatters, and badge tones) so every
|
|
24
|
+
* resource has a useful detail page with zero extra config.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveInfolist(components: InfolistComponent[], columns: Column[]): Section[] {
|
|
27
|
+
if (components.length > 0) return toSections(components);
|
|
28
|
+
|
|
29
|
+
const entries = columns.map((c) => {
|
|
30
|
+
const e = new Entry(c._key).label(c.getLabel());
|
|
31
|
+
if (c._format) e.format(c._format);
|
|
32
|
+
if (c._badge) {
|
|
33
|
+
e.badge().color((value, row) => c._badge!(value, row));
|
|
34
|
+
}
|
|
35
|
+
return e;
|
|
36
|
+
});
|
|
37
|
+
return [new Section().schema(entries)];
|
|
38
|
+
}
|