@nest-my-admin/core 0.3.1 → 0.4.1
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/README.md +33 -0
- package/dist/admin.module.js +6 -0
- package/dist/admin.module.js.map +1 -1
- package/dist/api/action.service.js +3 -0
- package/dist/api/action.service.js.map +1 -1
- package/dist/api/admin-api.service.d.ts +34 -2
- package/dist/api/admin-api.service.js +210 -11
- package/dist/api/admin-api.service.js.map +1 -1
- package/dist/api/menu-routes.d.ts +16 -0
- package/dist/api/menu-routes.js +10 -0
- package/dist/api/menu-routes.js.map +1 -0
- package/dist/api/menu.d.ts +15 -0
- package/dist/api/menu.js +85 -0
- package/dist/api/menu.js.map +1 -0
- package/dist/appearance/appearance.service.d.ts +28 -0
- package/dist/appearance/appearance.service.js +88 -0
- package/dist/appearance/appearance.service.js.map +1 -0
- package/dist/appearance/entities.d.ts +8 -0
- package/dist/appearance/entities.js +35 -0
- package/dist/appearance/entities.js.map +1 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/constants.js.map +1 -1
- package/dist/contract.d.ts +245 -7
- package/dist/crud/computed-columns.d.ts +65 -0
- package/dist/crud/computed-columns.js +264 -0
- package/dist/crud/computed-columns.js.map +1 -0
- package/dist/crud/list-query-builder.d.ts +3 -1
- package/dist/crud/list-query-builder.js +5 -2
- package/dist/crud/list-query-builder.js.map +1 -1
- package/dist/crud/list-query.js +6 -1
- package/dist/crud/list-query.js.map +1 -1
- package/dist/crud/list-tabs.d.ts +7 -0
- package/dist/crud/list-tabs.js +44 -0
- package/dist/crud/list-tabs.js.map +1 -0
- package/dist/dashboard/dashboard-routes.d.ts +18 -0
- package/dist/dashboard/dashboard-routes.js +13 -0
- package/dist/dashboard/dashboard-routes.js.map +1 -0
- package/dist/dashboard/dashboard.service.d.ts +20 -0
- package/dist/dashboard/dashboard.service.js +80 -0
- package/dist/dashboard/dashboard.service.js.map +1 -0
- package/dist/dashboard/widget-registry.d.ts +22 -0
- package/dist/dashboard/widget-registry.js +77 -0
- package/dist/dashboard/widget-registry.js.map +1 -0
- package/dist/decorators/admin-action.d.ts +15 -2
- package/dist/decorators/admin-action.js +21 -2
- package/dist/decorators/admin-action.js.map +1 -1
- package/dist/decorators/admin-field.d.ts +23 -3
- package/dist/decorators/admin-field.js +1 -1
- package/dist/decorators/admin-field.js.map +1 -1
- package/dist/decorators/admin-resource.d.ts +14 -0
- package/dist/decorators/admin-resource.js.map +1 -1
- package/dist/decorators/admin-widget.d.ts +27 -0
- package/dist/decorators/admin-widget.js +14 -0
- package/dist/decorators/admin-widget.js.map +1 -0
- package/dist/http/admin-http.server.d.ts +5 -1
- package/dist/http/admin-http.server.js +17 -2
- package/dist/http/admin-http.server.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/inlines/inline-config.d.ts +3 -0
- package/dist/inlines/inline-config.js +11 -3
- package/dist/inlines/inline-config.js.map +1 -1
- package/dist/inlines/inline-reader.d.ts +5 -0
- package/dist/inlines/inline-reader.js +9 -2
- package/dist/inlines/inline-reader.js.map +1 -1
- package/dist/inlines/inline-writer.js +4 -0
- package/dist/inlines/inline-writer.js.map +1 -1
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/options.d.ts +49 -0
- package/dist/options.js +31 -0
- package/dist/options.js.map +1 -1
- package/dist/policy/admin-policy.service.d.ts +10 -1
- package/dist/policy/admin-policy.service.js +112 -11
- package/dist/policy/admin-policy.service.js.map +1 -1
- package/dist/policy/catalog.d.ts +2 -2
- package/dist/policy/catalog.js +3 -3
- package/dist/policy/catalog.js.map +1 -1
- package/dist/policy/explain.js +3 -1
- package/dist/policy/explain.js.map +1 -1
- package/dist/rbac/rbac.service.d.ts +3 -1
- package/dist/rbac/rbac.service.js +8 -4
- package/dist/rbac/rbac.service.js.map +1 -1
- package/dist/registry/resource-registry.d.ts +17 -0
- package/dist/registry/resource-registry.js +75 -0
- package/dist/registry/resource-registry.js.map +1 -1
- package/dist/resource/admin-resource-base.d.ts +111 -9
- package/dist/resource/admin-resource-base.js +16 -2
- package/dist/resource/admin-resource-base.js.map +1 -1
- package/dist/schema/build-resource-schema.js +105 -7
- package/dist/schema/build-resource-schema.js.map +1 -1
- package/dist/schema/currency.d.ts +9 -0
- package/dist/schema/currency.js +11 -0
- package/dist/schema/currency.js.map +1 -0
- package/dist/schema/field-config.d.ts +33 -4
- package/dist/schema/field-config.js +68 -3
- package/dist/schema/field-config.js.map +1 -1
- package/package.json +2 -2
package/dist/contract.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface FieldSchema {
|
|
|
38
38
|
help?: string;
|
|
39
39
|
placeholder?: string;
|
|
40
40
|
/** The widget chosen in the field config; the UI infers one from the type when absent. */
|
|
41
|
-
widget?: 'text' | 'textarea' | 'number' | 'money' | 'switch' | 'checkbox' | 'select' | 'radio' | 'date' | 'datetime' | 'json' | 'color' | 'badge' | 'slug' | 'password' | 'email' | 'url' | 'file' | 'image' | 'richtext';
|
|
41
|
+
widget?: 'text' | 'textarea' | 'number' | 'money' | 'switch' | 'checkbox' | 'select' | 'radio' | 'date' | 'datetime' | 'json' | 'color' | 'badge' | 'slug' | 'password' | 'email' | 'url' | 'file' | 'image' | 'richtext' | 'stepper';
|
|
42
42
|
/** Display labels of enum values (values sent stay the raw ones). */
|
|
43
43
|
enumLabels?: Record<string, string>;
|
|
44
44
|
/** Badge colour per value. */
|
|
@@ -47,8 +47,22 @@ export interface FieldSchema {
|
|
|
47
47
|
showIf?: Record<string, string | number | boolean | null | Array<string | number | boolean | null>>;
|
|
48
48
|
/** The slug widget fills itself from this field until edited. */
|
|
49
49
|
slugFrom?: string;
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* `stepper` widget: the steps in order, the values that are not steps (shown as a badge), and whether records carry
|
|
52
|
+
* `_steps` (when each step was reached, from the history child).
|
|
53
|
+
*/
|
|
54
|
+
steps?: {
|
|
55
|
+
values: string[];
|
|
56
|
+
exclude: string[];
|
|
57
|
+
history: boolean;
|
|
58
|
+
};
|
|
59
|
+
/** A soft warning shown under the input while it is empty; the save still goes through. */
|
|
60
|
+
warnIf?: {
|
|
61
|
+
empty: true;
|
|
62
|
+
message: string;
|
|
63
|
+
};
|
|
64
|
+
/** The money widget's unit in the request's language, and where it goes (display only). */
|
|
65
|
+
currency?: CurrencyDisplay;
|
|
52
66
|
/** Set for `file` and `image` widgets: what may be uploaded to the field. */
|
|
53
67
|
file?: {
|
|
54
68
|
accept: string[];
|
|
@@ -125,12 +139,30 @@ export interface FormLayoutInline {
|
|
|
125
139
|
max: number;
|
|
126
140
|
/** The child's field that points at the parent (set by the server). */
|
|
127
141
|
link: string;
|
|
142
|
+
/** Shown, never edited in this form (writes are refused). */
|
|
143
|
+
readonly?: true;
|
|
144
|
+
/** The child's computed columns shown in the table (`_computed` on each row), cut to what this user may see. */
|
|
145
|
+
computed?: string[];
|
|
146
|
+
/** A marker on the first card by `order` («Main picture»). */
|
|
147
|
+
markFirst?: string;
|
|
148
|
+
}
|
|
149
|
+
/** Label and value rows in a card (an order's totals): a field of the record, or one of its computed columns (`_computed`). */
|
|
150
|
+
export interface FormLayoutSummary {
|
|
151
|
+
title?: string;
|
|
152
|
+
summary: Array<{
|
|
153
|
+
label: string;
|
|
154
|
+
value: {
|
|
155
|
+
field: string;
|
|
156
|
+
} | {
|
|
157
|
+
computed: string;
|
|
158
|
+
};
|
|
159
|
+
}>;
|
|
128
160
|
}
|
|
129
161
|
export interface FormLayoutTab {
|
|
130
162
|
tab: string;
|
|
131
|
-
sections: Array<FormLayoutSection | FormLayoutInline>;
|
|
163
|
+
sections: Array<FormLayoutSection | FormLayoutInline | FormLayoutSummary>;
|
|
132
164
|
}
|
|
133
|
-
export type FormLayoutNode = FormLayoutSection | FormLayoutTab | FormLayoutInline;
|
|
165
|
+
export type FormLayoutNode = FormLayoutSection | FormLayoutTab | FormLayoutInline | FormLayoutSummary;
|
|
134
166
|
/** One inline of a record (`_inlines[child]` on `GET /resources/:parent/:id`). */
|
|
135
167
|
export interface InlineRows {
|
|
136
168
|
/** The children of this record, as the child's own endpoints answer them (`_id`, `_perm`, `_readonly`, `_files`…), by `order` then key. */
|
|
@@ -246,6 +278,8 @@ export interface ActionSchema {
|
|
|
246
278
|
/** Ask before running: `true` for the default question, or the question itself. */
|
|
247
279
|
confirm?: string | true;
|
|
248
280
|
variant: 'default' | 'destructive';
|
|
281
|
+
/** The record's next step: a filled button, last in the record's header (record actions without input, one per resource). */
|
|
282
|
+
primary?: true;
|
|
249
283
|
icon?: string;
|
|
250
284
|
/** The form to fill in before running; its values are sent as `input`. */
|
|
251
285
|
input?: DtoFormSchema;
|
|
@@ -336,7 +370,47 @@ export interface MetaResponse {
|
|
|
336
370
|
/** Languages people can switch between. */
|
|
337
371
|
locales: string[];
|
|
338
372
|
groups: MetaGroup[];
|
|
373
|
+
/** This user may see at least one dashboard widget: the dashboard replaces the home page. */
|
|
374
|
+
dashboard?: boolean;
|
|
375
|
+
/** The host's curated menu (`menu` option), cut to what this user reaches; absent without one (groups are the menu). */
|
|
376
|
+
menu?: MetaMenu;
|
|
339
377
|
}
|
|
378
|
+
/** One entry of a menu section. */
|
|
379
|
+
export type MetaMenuItem = {
|
|
380
|
+
kind: 'resource';
|
|
381
|
+
resource: string;
|
|
382
|
+
label: string;
|
|
383
|
+
icon?: string;
|
|
384
|
+
creatable: boolean;
|
|
385
|
+
searchable: boolean;
|
|
386
|
+
/** A count is served for it by `GET /api/menu/badges` (key `<section>.<resource>`). */
|
|
387
|
+
badge?: true;
|
|
388
|
+
} | {
|
|
389
|
+
kind: 'page';
|
|
390
|
+
page: string;
|
|
391
|
+
label: string;
|
|
392
|
+
icon?: string;
|
|
393
|
+
} | {
|
|
394
|
+
kind: 'link';
|
|
395
|
+
href: string;
|
|
396
|
+
label: string;
|
|
397
|
+
};
|
|
398
|
+
export interface MetaMenuSection {
|
|
399
|
+
key: string;
|
|
400
|
+
label: string;
|
|
401
|
+
icon?: string;
|
|
402
|
+
items: MetaMenuItem[];
|
|
403
|
+
}
|
|
404
|
+
export interface MetaMenu {
|
|
405
|
+
sections: MetaMenuSection[];
|
|
406
|
+
/** Everything visible that no section places (key `advanced`), collapsed by default. May be empty. */
|
|
407
|
+
advanced: MetaMenuSection;
|
|
408
|
+
}
|
|
409
|
+
/** `GET /api/menu/badges`: counts by `<section>.<resource>`, scoped to the viewer; a badge the viewer may not count is absent. */
|
|
410
|
+
export type MenuBadges = Record<string, {
|
|
411
|
+
count: number;
|
|
412
|
+
estimate: boolean;
|
|
413
|
+
}>;
|
|
340
414
|
export type SortDirection = 'asc' | 'desc';
|
|
341
415
|
export type FilterOperator = 'eq' | 'ne' | 'in' | 'nin' | 'lt' | 'lte' | 'gt' | 'gte' | 'between' | 'contains' | 'startsWith' | 'isNull';
|
|
342
416
|
/** A filterable field and the operators its column type supports (spec §11). */
|
|
@@ -375,10 +449,16 @@ export interface ResourceSchema {
|
|
|
375
449
|
badge?: string;
|
|
376
450
|
meta: string[];
|
|
377
451
|
};
|
|
452
|
+
/** Tabs over the list (`list.tabs`) this viewer may use: a tab writes `filter` to the URL, plus `tab=<key>` when `where`. */
|
|
453
|
+
tabs?: ListTabSchema[];
|
|
454
|
+
/** Computed columns (`list.computed`) this viewer may see, by key; their values are each record's `_computed`. */
|
|
455
|
+
computed?: Record<string, ComputedColumnSchema>;
|
|
378
456
|
};
|
|
379
457
|
/** False for the root of a single-table inheritance, or when the user may not create. */
|
|
380
458
|
creatable: boolean;
|
|
381
459
|
/** What the signed-in user may do (spec §6.1); records may narrow update/delete in `_perm`. */
|
|
460
|
+
/** `@AdminResource({ readonly: true })`: nothing is created, changed or deleted through the admin (actions still run). */
|
|
461
|
+
readonly?: boolean;
|
|
382
462
|
permissions?: {
|
|
383
463
|
create: boolean;
|
|
384
464
|
update: boolean;
|
|
@@ -411,8 +491,22 @@ export interface ResourceSchema {
|
|
|
411
491
|
requiredOnCreate: string[];
|
|
412
492
|
/** Shown on the update form but not writable (`readonly: true`); records add their own in `_readonly`. */
|
|
413
493
|
readonly: string[];
|
|
414
|
-
/** Sections, inlines and tabs (`form.layout
|
|
494
|
+
/** Sections, inlines and tabs (`form.layout`, or its main column); absent = one section in form order. */
|
|
415
495
|
layout?: FormLayoutNode[];
|
|
496
|
+
/** Read-only cards beside the main column (`form.layout.aside`), cut to what this user may see. */
|
|
497
|
+
aside?: AsideCardSchema[];
|
|
498
|
+
/** `view`: an existing record opens as text until «Edit». */
|
|
499
|
+
mode?: 'view';
|
|
500
|
+
/** The record's header: badge fields, and a subtitle of field values and computed columns (`_computed`). */
|
|
501
|
+
header?: {
|
|
502
|
+
badges?: string[];
|
|
503
|
+
subtitle?: Array<{
|
|
504
|
+
field: string;
|
|
505
|
+
} | {
|
|
506
|
+
computed: string;
|
|
507
|
+
}>;
|
|
508
|
+
stepper?: string;
|
|
509
|
+
};
|
|
416
510
|
constraints: {
|
|
417
511
|
create: Record<string, FieldConstraints>;
|
|
418
512
|
update: Record<string, FieldConstraints>;
|
|
@@ -433,6 +527,8 @@ export interface ResourceSchema {
|
|
|
433
527
|
* resources with such fields add `_files`: field name → `AdminFileRef` (`AdminFileRef[]` with `file.many`) for every
|
|
434
528
|
* field that holds a file. `GET /resources/:resource/:id` of a resource with inlines adds `_inlines`: child resource
|
|
435
529
|
* name → `InlineRows`, for each inline the user may see; so do the answers of its POST and PATCH, read after the save.
|
|
530
|
+
* List pages of a resource with `list.computed` add `_computed`: column key → `ComputedValue`, for the computed columns
|
|
531
|
+
* the user may see and the page shows.
|
|
436
532
|
*/
|
|
437
533
|
export type AdminRecord = Record<string, unknown>;
|
|
438
534
|
/**
|
|
@@ -443,7 +539,8 @@ export type AdminRecord = Record<string, unknown>;
|
|
|
443
539
|
export interface RecordPermissions {
|
|
444
540
|
update: boolean;
|
|
445
541
|
delete: boolean;
|
|
446
|
-
actions
|
|
542
|
+
/** Record actions by name: the label to show on this record (`label.for`, else the action's label), or `false` when it may not run here. */
|
|
543
|
+
actions?: Record<string, string | false>;
|
|
447
544
|
}
|
|
448
545
|
/** `POST /resources/:resource/bulk-delete` with `{ ids }` (encoded record ids): each id succeeds or fails on its own. */
|
|
449
546
|
export interface BulkResult {
|
|
@@ -528,6 +625,11 @@ export interface SessionResponse {
|
|
|
528
625
|
enabled: boolean;
|
|
529
626
|
view: boolean;
|
|
530
627
|
};
|
|
628
|
+
/** With `appearance`: the admin-wide accent colour (null: the host's branding), and whether this user may change it. */
|
|
629
|
+
appearance?: {
|
|
630
|
+
accent: string | null;
|
|
631
|
+
manage: boolean;
|
|
632
|
+
};
|
|
531
633
|
/** Changes when roles or memberships change: refetch meta and schemas. */
|
|
532
634
|
permissionsVersion: number;
|
|
533
635
|
/** A superuser looking through another user's eyes (`X-View-As`): `user` is the target, `by` the superuser. Read-only. */
|
|
@@ -825,6 +927,137 @@ export interface AuditResponse {
|
|
|
825
927
|
}
|
|
826
928
|
/** A label in one language, or per language (`{ en: 'Orders', fa: 'سفارشها' }`). */
|
|
827
929
|
export type LocalizedLabel = string | Record<string, string>;
|
|
930
|
+
/** A status colour of the design (badges, computed cells, stat changes). */
|
|
931
|
+
export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'neutral' | 'primary';
|
|
932
|
+
/**
|
|
933
|
+
* An aside card (spec §5.6). Own fields come from the record; a relation card's from `_aside[index]` (the related
|
|
934
|
+
* record's fields this user may see, with its `_id`, absent when they may not see it), linking to it when `link`.
|
|
935
|
+
*/
|
|
936
|
+
export interface AsideCardSchema {
|
|
937
|
+
title: string;
|
|
938
|
+
icon?: string;
|
|
939
|
+
fields: string[];
|
|
940
|
+
relation?: {
|
|
941
|
+
field: string;
|
|
942
|
+
resource: string;
|
|
943
|
+
link: boolean;
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
/** Dashboard widgets (spec §5.8). */
|
|
947
|
+
export type WidgetType = 'stat' | 'chart' | 'table' | 'progress' | 'list';
|
|
948
|
+
/**
|
|
949
|
+
* The period a widget is asked for, computed by the UI in the browser's time zone (Jalali-aware): ISO instants, `to`
|
|
950
|
+
* excluded, and the window before it for comparisons. The server checks `from < to` and a span of at most 366 days.
|
|
951
|
+
*/
|
|
952
|
+
export interface WidgetParams {
|
|
953
|
+
period: 'day' | 'week' | 'month' | 'year' | 'range';
|
|
954
|
+
from: string;
|
|
955
|
+
to: string;
|
|
956
|
+
previousFrom: string;
|
|
957
|
+
previousTo: string;
|
|
958
|
+
}
|
|
959
|
+
/** `GET /api/dashboard`: the widgets this user may see, in order. */
|
|
960
|
+
export interface DashboardResponse {
|
|
961
|
+
widgets: Array<{
|
|
962
|
+
name: string;
|
|
963
|
+
type: WidgetType;
|
|
964
|
+
title: string;
|
|
965
|
+
size: 1 | 2 | 3 | 4;
|
|
966
|
+
}>;
|
|
967
|
+
}
|
|
968
|
+
export interface StatWidgetData {
|
|
969
|
+
/** Already formatted by the host or a plain number as text (the UI formats digits). */
|
|
970
|
+
value: string;
|
|
971
|
+
unit?: string;
|
|
972
|
+
/** Percent against the previous period. */
|
|
973
|
+
change?: number;
|
|
974
|
+
previousLabel?: string;
|
|
975
|
+
}
|
|
976
|
+
export interface ChartWidgetData {
|
|
977
|
+
kind: 'bar' | 'line';
|
|
978
|
+
labels: string[];
|
|
979
|
+
series: Array<{
|
|
980
|
+
label: string;
|
|
981
|
+
values: number[];
|
|
982
|
+
dashed?: boolean;
|
|
983
|
+
}>;
|
|
984
|
+
total?: string;
|
|
985
|
+
subtitle?: string;
|
|
986
|
+
}
|
|
987
|
+
export interface TableWidgetData {
|
|
988
|
+
columns: Array<{
|
|
989
|
+
key: string;
|
|
990
|
+
label: string;
|
|
991
|
+
}>;
|
|
992
|
+
/** Cell values by column key; a `{ label, tone }` cell is a status badge. */
|
|
993
|
+
rows: Array<Record<string, string | number | {
|
|
994
|
+
label: string;
|
|
995
|
+
tone: Tone;
|
|
996
|
+
}>>;
|
|
997
|
+
/** «View all»: the list with this filter. */
|
|
998
|
+
link?: {
|
|
999
|
+
resource: string;
|
|
1000
|
+
filter?: Record<string, string>;
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
export interface ProgressWidgetData {
|
|
1004
|
+
percent: number;
|
|
1005
|
+
value: string;
|
|
1006
|
+
caption: string;
|
|
1007
|
+
}
|
|
1008
|
+
export interface ListWidgetData {
|
|
1009
|
+
items: Array<{
|
|
1010
|
+
icon?: string;
|
|
1011
|
+
text: string;
|
|
1012
|
+
status?: Tone;
|
|
1013
|
+
link?: string;
|
|
1014
|
+
}>;
|
|
1015
|
+
}
|
|
1016
|
+
/** `GET /api/dashboard/:widget`: the data of the widget's type. */
|
|
1017
|
+
export type WidgetData = StatWidgetData | ChartWidgetData | TableWidgetData | ProgressWidgetData | ListWidgetData;
|
|
1018
|
+
/** A list tab (spec §5.5). */
|
|
1019
|
+
export interface ListTabSchema {
|
|
1020
|
+
key: string;
|
|
1021
|
+
label: string;
|
|
1022
|
+
/** `filter[...]` list parameters the tab sets (none: «all»). */
|
|
1023
|
+
filter: Record<string, string>;
|
|
1024
|
+
/** The tab restricts the list beyond its filters: send `tab=<key>`. */
|
|
1025
|
+
where: boolean;
|
|
1026
|
+
count: 'exact' | 'estimate' | 'none';
|
|
1027
|
+
}
|
|
1028
|
+
/** `GET /api/resources/:resource/tab-counts`: each tab's count for the viewer, `null` for `count: 'none'`. */
|
|
1029
|
+
export type TabCounts = Record<string, {
|
|
1030
|
+
count: number;
|
|
1031
|
+
estimate: boolean;
|
|
1032
|
+
} | null>;
|
|
1033
|
+
/** A computed list column (spec §5.4). */
|
|
1034
|
+
export interface ComputedColumnSchema {
|
|
1035
|
+
label: string;
|
|
1036
|
+
kind: 'image' | 'title' | 'money' | 'badge' | 'count' | 'toggle';
|
|
1037
|
+
sortable: boolean;
|
|
1038
|
+
/** `toggle`: the enum field a click writes (PATCH `{ [field]: on | off }`), when the viewer may edit it. */
|
|
1039
|
+
toggle?: {
|
|
1040
|
+
field: string;
|
|
1041
|
+
on: string;
|
|
1042
|
+
off: string;
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* A computed cell's value (`AdminRecord._computed[key]`): `value` is the main one (an image is its URL, a toggle
|
|
1047
|
+
* a boolean), `subline` and `previous` go with `title` and `money`, `label` and `tone` with `badge`.
|
|
1048
|
+
*/
|
|
1049
|
+
export interface ComputedValue {
|
|
1050
|
+
value: unknown;
|
|
1051
|
+
subline?: unknown;
|
|
1052
|
+
previous?: unknown;
|
|
1053
|
+
label?: string;
|
|
1054
|
+
tone?: Tone;
|
|
1055
|
+
}
|
|
1056
|
+
/** A money unit as shown: `۳٬۲۵۰٬۰۰۰ تومان` (after) or `$12.50` (before). */
|
|
1057
|
+
export interface CurrencyDisplay {
|
|
1058
|
+
unit: string;
|
|
1059
|
+
position: 'after' | 'before';
|
|
1060
|
+
}
|
|
828
1061
|
/** Host branding (spec §9.5). Colours and lengths are validated by core before they reach the page. */
|
|
829
1062
|
export interface BrandingConfig {
|
|
830
1063
|
name?: LocalizedLabel;
|
|
@@ -843,4 +1076,9 @@ export interface AdminRuntimeConfig {
|
|
|
843
1076
|
locale: string;
|
|
844
1077
|
locales: string[];
|
|
845
1078
|
branding: BrandingConfig;
|
|
1079
|
+
/** The global money unit, in every configured language (the UI picks its own). */
|
|
1080
|
+
currency?: {
|
|
1081
|
+
unit: LocalizedLabel;
|
|
1082
|
+
position: 'after' | 'before';
|
|
1083
|
+
};
|
|
846
1084
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EntityMetadata, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|
2
|
+
import type { ComputedValue, FieldSchema } from '../contract.js';
|
|
3
|
+
import type { LocalizedText } from '../i18n/localized-text.js';
|
|
4
|
+
import type { AdminContext } from '../resource/admin-context.js';
|
|
5
|
+
import type { ComputedColumn } from '../resource/admin-resource-base.js';
|
|
6
|
+
type RelationMeta = EntityMetadata['relations'][number];
|
|
7
|
+
/** A source once checked against the entity: what to read and how. */
|
|
8
|
+
export type CompiledSource = {
|
|
9
|
+
type: 'field';
|
|
10
|
+
path: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: 'aggregate';
|
|
13
|
+
fn: 'min' | 'max' | 'sum' | 'count';
|
|
14
|
+
relation: RelationMeta;
|
|
15
|
+
field?: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'first';
|
|
18
|
+
relation: RelationMeta;
|
|
19
|
+
field: string;
|
|
20
|
+
by: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'toggle';
|
|
23
|
+
field: string;
|
|
24
|
+
on: string;
|
|
25
|
+
off: string;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'compute';
|
|
28
|
+
fn: (rows: any[], ctx: AdminContext) => Promise<Record<string, unknown>>;
|
|
29
|
+
fields: string[];
|
|
30
|
+
};
|
|
31
|
+
export interface CompiledComputed {
|
|
32
|
+
key: string;
|
|
33
|
+
column: ComputedColumn;
|
|
34
|
+
source: CompiledSource;
|
|
35
|
+
subline?: CompiledSource;
|
|
36
|
+
previous?: CompiledSource;
|
|
37
|
+
/** What it reads, for the policy: own fields and paths, and the child entities (and their fields) it aggregates. */
|
|
38
|
+
deps: {
|
|
39
|
+
own: string[];
|
|
40
|
+
children: Array<{
|
|
41
|
+
entity: Function;
|
|
42
|
+
fields: string[];
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Checks `list.computed` at boot (spec §5.4) and compiles each column: sources exist, relations are one-to-many of a
|
|
48
|
+
* single-key entity, `sortable` only on aggregates and first rows, a toggle's values are its enum's, `map` only on badges.
|
|
49
|
+
*/
|
|
50
|
+
export declare function checkComputed(computed: Record<string, ComputedColumn> | undefined, metadata: EntityMetadata, enumValues: (field: string) => string[] | undefined, fail: (message: string) => never): Map<string, CompiledComputed>;
|
|
51
|
+
/** A query of a child entity as the viewer may read it (the child resource's `query()` and scopes), or a plain one. */
|
|
52
|
+
export type ChildQuery = (entity: Function, alias: string, ctx: AdminContext | undefined) => SelectQueryBuilder<ObjectLiteral>;
|
|
53
|
+
/**
|
|
54
|
+
* The page's computed values, by record id: one query per relation or path source and one `compute` call, whatever the
|
|
55
|
+
* page size (no N+1). Badge labels are localized with `text`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function loadComputed(columns: CompiledComputed[], rows: ObjectLiteral[], metadata: EntityMetadata, ctx: AdminContext, childQuery: ChildQuery, pathQuery: (alias: string) => SelectQueryBuilder<ObjectLiteral>, text: (label: LocalizedText) => string,
|
|
58
|
+
/** A field's schema (to serialize decimals as strings, dates as ISO…): the entity's resource field, if any. */
|
|
59
|
+
fieldFor: (entity: Function, name: string) => FieldSchema | undefined): Promise<Map<string, Record<string, ComputedValue>>>;
|
|
60
|
+
/**
|
|
61
|
+
* Sorting by an aggregate or first-row column: a correlated subquery over the rows the viewer may read, selected as
|
|
62
|
+
* `nma_sort` so paging with joins can order by it. Returns the expression to order by.
|
|
63
|
+
*/
|
|
64
|
+
export declare function addComputedSort(qb: SelectQueryBuilder<ObjectLiteral>, compiled: CompiledComputed, metadata: EntityMetadata, ctx: AdminContext | undefined, childQuery: ChildQuery): string;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { resolvePath } from '../schema/relation-fields.js';
|
|
2
|
+
import { recordIdOf } from './record-id.js';
|
|
3
|
+
import { serializeValue } from './serialize.js';
|
|
4
|
+
const KINDS = ['image', 'title', 'money', 'badge', 'count', 'toggle'];
|
|
5
|
+
const AGGREGATES = ['min', 'max', 'sum', 'count'];
|
|
6
|
+
/**
|
|
7
|
+
* Checks `list.computed` at boot (spec §5.4) and compiles each column: sources exist, relations are one-to-many of a
|
|
8
|
+
* single-key entity, `sortable` only on aggregates and first rows, a toggle's values are its enum's, `map` only on badges.
|
|
9
|
+
*/
|
|
10
|
+
export function checkComputed(computed, metadata, enumValues, fail) {
|
|
11
|
+
const out = new Map();
|
|
12
|
+
for (const [key, column] of Object.entries(computed ?? {})) {
|
|
13
|
+
const where = `list.computed.${key}`;
|
|
14
|
+
if (!KINDS.includes(column.kind))
|
|
15
|
+
fail(`${where}: unknown kind "${String(column.kind)}" (use ${KINDS.join(', ')})`);
|
|
16
|
+
const deps = { own: [], children: [] };
|
|
17
|
+
const compile = (source, part) => compileSource(source, `${where}.${part}`, metadata, enumValues, deps, fail);
|
|
18
|
+
const source = compile(column.source, 'source');
|
|
19
|
+
if (column.sortable && source.type !== 'aggregate' && source.type !== 'first')
|
|
20
|
+
fail(`${where}: only aggregate and first sources can be sortable`);
|
|
21
|
+
if (column.map !== undefined && (column.kind !== 'badge' || typeof column.map !== 'function'))
|
|
22
|
+
fail(`${where}: \`map\` is a function, for badges only`);
|
|
23
|
+
if ((column.kind === 'toggle') !== (source.type === 'toggle'))
|
|
24
|
+
fail(`${where}: a toggle column needs a { toggle, on, off } source, and only it`);
|
|
25
|
+
if (column.subline !== undefined && column.kind !== 'title')
|
|
26
|
+
fail(`${where}: \`subline\` goes with the title kind`);
|
|
27
|
+
if (column.previous !== undefined && column.kind !== 'money')
|
|
28
|
+
fail(`${where}: \`previous\` goes with the money kind`);
|
|
29
|
+
out.set(key, {
|
|
30
|
+
key,
|
|
31
|
+
column,
|
|
32
|
+
source,
|
|
33
|
+
...(column.subline !== undefined ? { subline: compile(column.subline, 'subline') } : {}),
|
|
34
|
+
...(column.previous !== undefined ? { previous: compile(column.previous, 'previous') } : {}),
|
|
35
|
+
deps,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function compileSource(source, where, metadata, enumValues, deps, fail) {
|
|
41
|
+
const ownField = (name, what) => {
|
|
42
|
+
if (!metadata.findColumnsWithPropertyPath(name).length && !metadata.relations.some((relation) => relation.propertyName === name))
|
|
43
|
+
fail(`${where}: unknown ${what} "${name}" on ${metadata.name}`);
|
|
44
|
+
deps.own.push(name);
|
|
45
|
+
};
|
|
46
|
+
const toMany = (name) => {
|
|
47
|
+
if (metadata.primaryColumns.length !== 1)
|
|
48
|
+
fail(`${where}: relation sources need an entity with one primary key column`);
|
|
49
|
+
const relation = metadata.relations.find((candidate) => candidate.propertyName === name);
|
|
50
|
+
if (!relation)
|
|
51
|
+
fail(`${where}: unknown relation "${name}" on ${metadata.name}`);
|
|
52
|
+
if (relation.relationType !== 'one-to-many' || !relation.inverseRelation)
|
|
53
|
+
fail(`${where}: "${name}" is not a one-to-many relation`);
|
|
54
|
+
return relation;
|
|
55
|
+
};
|
|
56
|
+
const childField = (relation, name) => {
|
|
57
|
+
const child = relation.inverseEntityMetadata;
|
|
58
|
+
if (!child.findColumnsWithPropertyPath(name).length)
|
|
59
|
+
fail(`${where}: unknown field "${name}" on ${child.name}`);
|
|
60
|
+
const entry = deps.children.find((candidate) => candidate.entity === child.target);
|
|
61
|
+
if (entry)
|
|
62
|
+
entry.fields.push(name);
|
|
63
|
+
else
|
|
64
|
+
deps.children.push({ entity: child.target, fields: [name] });
|
|
65
|
+
};
|
|
66
|
+
if (typeof source === 'string') {
|
|
67
|
+
if (source.includes('.')) {
|
|
68
|
+
const resolved = resolvePath(metadata, source);
|
|
69
|
+
if ('error' in resolved)
|
|
70
|
+
fail(`${where}: cannot use "${source}": ${resolved.error}`);
|
|
71
|
+
deps.own.push(source);
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
ownField(source, 'field');
|
|
75
|
+
return { type: 'field', path: source };
|
|
76
|
+
}
|
|
77
|
+
if ('aggregate' in source) {
|
|
78
|
+
if (!AGGREGATES.includes(source.aggregate))
|
|
79
|
+
fail(`${where}: unknown aggregate "${String(source.aggregate)}"`);
|
|
80
|
+
const relation = toMany(source.relation);
|
|
81
|
+
if (source.aggregate !== 'count' && !source.field)
|
|
82
|
+
fail(`${where}: ${source.aggregate} needs a field`);
|
|
83
|
+
if (source.field)
|
|
84
|
+
childField(relation, source.field);
|
|
85
|
+
else if (!deps.children.some((entry) => entry.entity === relation.inverseEntityMetadata.target))
|
|
86
|
+
deps.children.push({ entity: relation.inverseEntityMetadata.target, fields: [] });
|
|
87
|
+
return { type: 'aggregate', fn: source.aggregate, relation, ...(source.field ? { field: source.field } : {}) };
|
|
88
|
+
}
|
|
89
|
+
if ('first' in source) {
|
|
90
|
+
const relation = toMany(source.first);
|
|
91
|
+
childField(relation, source.field);
|
|
92
|
+
childField(relation, source.by);
|
|
93
|
+
return { type: 'first', relation, field: source.field, by: source.by };
|
|
94
|
+
}
|
|
95
|
+
if ('toggle' in source) {
|
|
96
|
+
ownField(source.toggle, 'field');
|
|
97
|
+
const values = enumValues(source.toggle);
|
|
98
|
+
if (!values)
|
|
99
|
+
fail(`${where}: "${source.toggle}" is not an enum field`);
|
|
100
|
+
for (const value of [source.on, source.off])
|
|
101
|
+
if (!values.includes(value))
|
|
102
|
+
fail(`${where}: "${value}" is not one of ${values.join(', ')}`);
|
|
103
|
+
return { type: 'toggle', field: source.toggle, on: source.on, off: source.off };
|
|
104
|
+
}
|
|
105
|
+
if ('compute' in source) {
|
|
106
|
+
if (typeof source.compute !== 'function')
|
|
107
|
+
fail(`${where}: compute must be a function`);
|
|
108
|
+
if (!Array.isArray(source.fields))
|
|
109
|
+
fail(`${where}: compute needs \`fields\`, the fields it reads (a viewer who cannot see one does not get the column)`);
|
|
110
|
+
for (const field of source.fields)
|
|
111
|
+
ownField(field, 'field');
|
|
112
|
+
return { type: 'compute', fn: source.compute, fields: source.fields };
|
|
113
|
+
}
|
|
114
|
+
return fail(`${where}: unknown source`);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The page's computed values, by record id: one query per relation or path source and one `compute` call, whatever the
|
|
118
|
+
* page size (no N+1). Badge labels are localized with `text`.
|
|
119
|
+
*/
|
|
120
|
+
export async function loadComputed(columns, rows, metadata, ctx, childQuery, pathQuery, text,
|
|
121
|
+
/** A field's schema (to serialize decimals as strings, dates as ISO…): the entity's resource field, if any. */
|
|
122
|
+
fieldFor) {
|
|
123
|
+
const keys = metadata.primaryColumns.map((column) => column.propertyName);
|
|
124
|
+
const out = new Map(rows.map((row) => [recordIdOf(row, keys), {}]));
|
|
125
|
+
if (rows.length === 0 || columns.length === 0)
|
|
126
|
+
return out;
|
|
127
|
+
const pk = keys[0];
|
|
128
|
+
const ids = rows.map((row) => row[pk]);
|
|
129
|
+
const cache = new Map();
|
|
130
|
+
const resolve = (source) => {
|
|
131
|
+
const id = sourceId(source);
|
|
132
|
+
let found = cache.get(id);
|
|
133
|
+
if (!found)
|
|
134
|
+
cache.set(id, (found = read(source)));
|
|
135
|
+
return found;
|
|
136
|
+
};
|
|
137
|
+
const read = async (source) => {
|
|
138
|
+
const raw = await readRaw(source);
|
|
139
|
+
const field = source.type === 'field'
|
|
140
|
+
? fieldFor(metadata.target, source.path)
|
|
141
|
+
: source.type === 'first' || (source.type === 'aggregate' && source.fn !== 'count' && source.field)
|
|
142
|
+
? fieldFor(source.relation.inverseEntityMetadata.target, source.field)
|
|
143
|
+
: undefined;
|
|
144
|
+
if (!field)
|
|
145
|
+
return raw;
|
|
146
|
+
// Drivers answer SUM/MIN/MAX of an integer as text (Postgres bigint): a number field stays a number.
|
|
147
|
+
const typed = (value) => (field.type === 'number' && typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value)) ? Number(value) : value);
|
|
148
|
+
return new Map([...raw].map(([id, value]) => [id, serializeValue(typed(value), field)]));
|
|
149
|
+
};
|
|
150
|
+
const readRaw = async (source) => {
|
|
151
|
+
const byRow = new Map();
|
|
152
|
+
switch (source.type) {
|
|
153
|
+
case 'field': {
|
|
154
|
+
if (!source.path.includes('.')) {
|
|
155
|
+
for (const row of rows)
|
|
156
|
+
byRow.set(recordIdOf(row, keys), row[source.path] ?? null);
|
|
157
|
+
return byRow;
|
|
158
|
+
}
|
|
159
|
+
const qb = pathQuery('nma_p');
|
|
160
|
+
const segments = source.path.split('.');
|
|
161
|
+
let alias = 'nma_p';
|
|
162
|
+
segments.slice(0, -1).forEach((segment, index) => {
|
|
163
|
+
qb.leftJoin(`${alias}.${segment}`, `nma_j${index}`);
|
|
164
|
+
alias = `nma_j${index}`;
|
|
165
|
+
});
|
|
166
|
+
const raw = await qb.select(`nma_p.${pk}`, 'k').addSelect(`${alias}.${segments.at(-1)}`, 'v').where(`nma_p.${pk} IN (:...nmaIds)`, { nmaIds: ids }).getRawMany();
|
|
167
|
+
for (const { k, v } of raw)
|
|
168
|
+
byRow.set(String(k), v ?? null);
|
|
169
|
+
return byRow;
|
|
170
|
+
}
|
|
171
|
+
case 'aggregate': {
|
|
172
|
+
const child = source.relation.inverseEntityMetadata;
|
|
173
|
+
const fk = `nma_c.${source.relation.inverseRelation.propertyName}`;
|
|
174
|
+
const target = source.field ? `nma_c.${source.field}` : `nma_c.${child.primaryColumns[0].propertyName}`;
|
|
175
|
+
const raw = await childQuery(child.target, 'nma_c', ctx)
|
|
176
|
+
.select(fk, 'k')
|
|
177
|
+
.addSelect(`${source.fn.toUpperCase()}(${target})`, 'v')
|
|
178
|
+
.andWhere(`${fk} IN (:...nmaIds)`, { nmaIds: ids })
|
|
179
|
+
.groupBy(fk)
|
|
180
|
+
.getRawMany();
|
|
181
|
+
for (const { k, v } of raw)
|
|
182
|
+
byRow.set(String(k), source.fn === 'count' ? Number(v) : v);
|
|
183
|
+
if (source.fn === 'count')
|
|
184
|
+
for (const row of rows)
|
|
185
|
+
if (!byRow.has(recordIdOf(row, keys)))
|
|
186
|
+
byRow.set(recordIdOf(row, keys), 0);
|
|
187
|
+
return byRow;
|
|
188
|
+
}
|
|
189
|
+
case 'first': {
|
|
190
|
+
const child = source.relation.inverseEntityMetadata;
|
|
191
|
+
const fk = `nma_c.${source.relation.inverseRelation.propertyName}`;
|
|
192
|
+
const raw = await childQuery(child.target, 'nma_c', ctx)
|
|
193
|
+
.select(fk, 'k')
|
|
194
|
+
.addSelect(`nma_c.${source.field}`, 'v')
|
|
195
|
+
.andWhere(`${fk} IN (:...nmaIds)`, { nmaIds: ids })
|
|
196
|
+
.orderBy(`nma_c.${source.by}`, 'ASC')
|
|
197
|
+
.addOrderBy(`nma_c.${child.primaryColumns[0].propertyName}`, 'ASC')
|
|
198
|
+
.getRawMany();
|
|
199
|
+
for (const { k, v } of raw)
|
|
200
|
+
if (!byRow.has(String(k)))
|
|
201
|
+
byRow.set(String(k), v ?? null);
|
|
202
|
+
return byRow;
|
|
203
|
+
}
|
|
204
|
+
case 'toggle':
|
|
205
|
+
for (const row of rows)
|
|
206
|
+
byRow.set(recordIdOf(row, keys), row[source.field] === source.on);
|
|
207
|
+
return byRow;
|
|
208
|
+
case 'compute': {
|
|
209
|
+
const values = await source.fn(rows, ctx);
|
|
210
|
+
for (const row of rows)
|
|
211
|
+
byRow.set(recordIdOf(row, keys), values[recordIdOf(row, keys)] ?? null);
|
|
212
|
+
return byRow;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
for (const compiled of columns) {
|
|
217
|
+
const [main, subline, previous] = await Promise.all([resolve(compiled.source), compiled.subline && resolve(compiled.subline), compiled.previous && resolve(compiled.previous)]);
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
const id = recordIdOf(row, keys);
|
|
220
|
+
const value = main.get(id) ?? null;
|
|
221
|
+
const cell = { value };
|
|
222
|
+
if (subline)
|
|
223
|
+
cell.subline = subline.get(id) ?? null;
|
|
224
|
+
if (previous)
|
|
225
|
+
cell.previous = previous.get(id) ?? null;
|
|
226
|
+
if (compiled.column.map) {
|
|
227
|
+
const mapped = compiled.column.map(value, row);
|
|
228
|
+
cell.label = text(mapped.label);
|
|
229
|
+
cell.tone = mapped.tone;
|
|
230
|
+
}
|
|
231
|
+
out.get(id)[compiled.key] = cell;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
const sourceId = (source) => source.type === 'field'
|
|
237
|
+
? `f:${source.path}`
|
|
238
|
+
: source.type === 'aggregate'
|
|
239
|
+
? `a:${source.fn}:${source.relation.propertyName}:${source.field ?? ''}`
|
|
240
|
+
: source.type === 'first'
|
|
241
|
+
? `1:${source.relation.propertyName}:${source.field}:${source.by}`
|
|
242
|
+
: source.type === 'toggle'
|
|
243
|
+
? `t:${source.field}`
|
|
244
|
+
: `c:${Math.random()}`; // each compute function runs once anyway
|
|
245
|
+
/**
|
|
246
|
+
* Sorting by an aggregate or first-row column: a correlated subquery over the rows the viewer may read, selected as
|
|
247
|
+
* `nma_sort` so paging with joins can order by it. Returns the expression to order by.
|
|
248
|
+
*/
|
|
249
|
+
export function addComputedSort(qb, compiled, metadata, ctx, childQuery) {
|
|
250
|
+
const source = compiled.source;
|
|
251
|
+
if (source.type !== 'aggregate' && source.type !== 'first')
|
|
252
|
+
throw new Error(`nest-my-admin: ${compiled.key} is not sortable`);
|
|
253
|
+
const child = source.relation.inverseEntityMetadata;
|
|
254
|
+
const childPk = child.primaryColumns[0].propertyName;
|
|
255
|
+
const sub = childQuery(child.target, 'nma_s', ctx).andWhere(`nma_s.${source.relation.inverseRelation.propertyName} = ${qb.alias}.${metadata.primaryColumns[0].propertyName}`);
|
|
256
|
+
// A sum over no rows is 0, not NULL: drivers place NULLs differently when sorting (first in Postgres descending).
|
|
257
|
+
if (source.type === 'aggregate')
|
|
258
|
+
sub.select(source.fn === 'sum' ? `COALESCE(SUM(nma_s.${source.field}), 0)` : `${source.fn.toUpperCase()}(nma_s.${source.field ?? childPk})`);
|
|
259
|
+
else
|
|
260
|
+
sub.select(`nma_s.${source.field}`).orderBy(`nma_s.${source.by}`, 'ASC').addOrderBy(`nma_s.${childPk}`, 'ASC').limit(1);
|
|
261
|
+
qb.addSelect(`(${sub.getQuery()})`, 'nma_sort').setParameters({ ...qb.getParameters(), ...sub.getParameters() });
|
|
262
|
+
return 'nma_sort';
|
|
263
|
+
}
|
|
264
|
+
//# sourceMappingURL=computed-columns.js.map
|