@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.
Files changed (101) hide show
  1. package/README.md +33 -0
  2. package/dist/admin.module.js +6 -0
  3. package/dist/admin.module.js.map +1 -1
  4. package/dist/api/action.service.js +3 -0
  5. package/dist/api/action.service.js.map +1 -1
  6. package/dist/api/admin-api.service.d.ts +34 -2
  7. package/dist/api/admin-api.service.js +210 -11
  8. package/dist/api/admin-api.service.js.map +1 -1
  9. package/dist/api/menu-routes.d.ts +16 -0
  10. package/dist/api/menu-routes.js +10 -0
  11. package/dist/api/menu-routes.js.map +1 -0
  12. package/dist/api/menu.d.ts +15 -0
  13. package/dist/api/menu.js +85 -0
  14. package/dist/api/menu.js.map +1 -0
  15. package/dist/appearance/appearance.service.d.ts +28 -0
  16. package/dist/appearance/appearance.service.js +88 -0
  17. package/dist/appearance/appearance.service.js.map +1 -0
  18. package/dist/appearance/entities.d.ts +8 -0
  19. package/dist/appearance/entities.js +35 -0
  20. package/dist/appearance/entities.js.map +1 -0
  21. package/dist/constants.d.ts +1 -0
  22. package/dist/constants.js +1 -0
  23. package/dist/constants.js.map +1 -1
  24. package/dist/contract.d.ts +245 -7
  25. package/dist/crud/computed-columns.d.ts +65 -0
  26. package/dist/crud/computed-columns.js +264 -0
  27. package/dist/crud/computed-columns.js.map +1 -0
  28. package/dist/crud/list-query-builder.d.ts +3 -1
  29. package/dist/crud/list-query-builder.js +5 -2
  30. package/dist/crud/list-query-builder.js.map +1 -1
  31. package/dist/crud/list-query.js +6 -1
  32. package/dist/crud/list-query.js.map +1 -1
  33. package/dist/crud/list-tabs.d.ts +7 -0
  34. package/dist/crud/list-tabs.js +44 -0
  35. package/dist/crud/list-tabs.js.map +1 -0
  36. package/dist/dashboard/dashboard-routes.d.ts +18 -0
  37. package/dist/dashboard/dashboard-routes.js +13 -0
  38. package/dist/dashboard/dashboard-routes.js.map +1 -0
  39. package/dist/dashboard/dashboard.service.d.ts +20 -0
  40. package/dist/dashboard/dashboard.service.js +80 -0
  41. package/dist/dashboard/dashboard.service.js.map +1 -0
  42. package/dist/dashboard/widget-registry.d.ts +22 -0
  43. package/dist/dashboard/widget-registry.js +77 -0
  44. package/dist/dashboard/widget-registry.js.map +1 -0
  45. package/dist/decorators/admin-action.d.ts +15 -2
  46. package/dist/decorators/admin-action.js +21 -2
  47. package/dist/decorators/admin-action.js.map +1 -1
  48. package/dist/decorators/admin-field.d.ts +23 -3
  49. package/dist/decorators/admin-field.js +1 -1
  50. package/dist/decorators/admin-field.js.map +1 -1
  51. package/dist/decorators/admin-resource.d.ts +14 -0
  52. package/dist/decorators/admin-resource.js.map +1 -1
  53. package/dist/decorators/admin-widget.d.ts +27 -0
  54. package/dist/decorators/admin-widget.js +14 -0
  55. package/dist/decorators/admin-widget.js.map +1 -0
  56. package/dist/http/admin-http.server.d.ts +5 -1
  57. package/dist/http/admin-http.server.js +17 -2
  58. package/dist/http/admin-http.server.js.map +1 -1
  59. package/dist/index.d.ts +4 -2
  60. package/dist/index.js +2 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/inlines/inline-config.d.ts +3 -0
  63. package/dist/inlines/inline-config.js +11 -3
  64. package/dist/inlines/inline-config.js.map +1 -1
  65. package/dist/inlines/inline-reader.d.ts +5 -0
  66. package/dist/inlines/inline-reader.js +9 -2
  67. package/dist/inlines/inline-reader.js.map +1 -1
  68. package/dist/inlines/inline-writer.js +4 -0
  69. package/dist/inlines/inline-writer.js.map +1 -1
  70. package/dist/internal.d.ts +3 -0
  71. package/dist/internal.js +2 -0
  72. package/dist/internal.js.map +1 -1
  73. package/dist/options.d.ts +49 -0
  74. package/dist/options.js +31 -0
  75. package/dist/options.js.map +1 -1
  76. package/dist/policy/admin-policy.service.d.ts +10 -1
  77. package/dist/policy/admin-policy.service.js +112 -11
  78. package/dist/policy/admin-policy.service.js.map +1 -1
  79. package/dist/policy/catalog.d.ts +2 -2
  80. package/dist/policy/catalog.js +3 -3
  81. package/dist/policy/catalog.js.map +1 -1
  82. package/dist/policy/explain.js +3 -1
  83. package/dist/policy/explain.js.map +1 -1
  84. package/dist/rbac/rbac.service.d.ts +3 -1
  85. package/dist/rbac/rbac.service.js +8 -4
  86. package/dist/rbac/rbac.service.js.map +1 -1
  87. package/dist/registry/resource-registry.d.ts +17 -0
  88. package/dist/registry/resource-registry.js +75 -0
  89. package/dist/registry/resource-registry.js.map +1 -1
  90. package/dist/resource/admin-resource-base.d.ts +111 -9
  91. package/dist/resource/admin-resource-base.js +16 -2
  92. package/dist/resource/admin-resource-base.js.map +1 -1
  93. package/dist/schema/build-resource-schema.js +105 -7
  94. package/dist/schema/build-resource-schema.js.map +1 -1
  95. package/dist/schema/currency.d.ts +9 -0
  96. package/dist/schema/currency.js +11 -0
  97. package/dist/schema/currency.js.map +1 -0
  98. package/dist/schema/field-config.d.ts +33 -4
  99. package/dist/schema/field-config.js +68 -3
  100. package/dist/schema/field-config.js.map +1 -1
  101. package/package.json +2 -2
@@ -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
- /** Currency code for the money widget (display only). */
51
- currency?: string;
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`); absent = one section in form order. */
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?: string[];
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