@opensaas/stack-core 0.29.0 → 0.31.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +443 -0
- package/CLAUDE.md +4 -4
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +206 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/mcp/handler.d.ts +11 -3
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +96 -20
- package/dist/mcp/handler.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +286 -4
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/src/mcp/handler.ts +118 -28
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AccessContext } from '../access/types.js'
|
|
2
|
+
import { getDbKey } from '../lib/case-utils.js'
|
|
3
|
+
import type { ListConfig, OpenSaasConfig, TypeInfo } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The minimal shape a `context.db[list]` delegate needs for a nav count. The
|
|
7
|
+
* access-controlled DB carries an `any` index signature, so this narrows the
|
|
8
|
+
* lookup to just the access-scoped `count` we call.
|
|
9
|
+
*/
|
|
10
|
+
type CountDelegate = {
|
|
11
|
+
count?: (args?: { where?: unknown }) => Promise<number>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a list's query access is **statically denied** — provably no session
|
|
16
|
+
* can read any row without evaluating a session-dependent function.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the list view's `canDeleteList` static check (issue #733): query
|
|
19
|
+
* access is statically denied when it is absent (deny-by-default, matching the
|
|
20
|
+
* access engine) or the literal boolean `false`. A function — or `true` —
|
|
21
|
+
* cannot be decided up front, so the count is fetched through the secured
|
|
22
|
+
* context, which re-applies the real rule per row.
|
|
23
|
+
*
|
|
24
|
+
* A statically-denied list must render no nav count at all: a `0` there would
|
|
25
|
+
* imply "this list is empty" when the truth is "you may see none of it".
|
|
26
|
+
*/
|
|
27
|
+
export function isListQueryStaticallyDenied(
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
29
|
+
listConfig: ListConfig<any>,
|
|
30
|
+
): boolean {
|
|
31
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
32
|
+
if (queryAccess === undefined) return true
|
|
33
|
+
if (typeof queryAccess === 'boolean') return !queryAccess
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the access-scoped nav counts for the lists that opt in via
|
|
39
|
+
* `ui.navCount` (issue #735).
|
|
40
|
+
*
|
|
41
|
+
* The returned map is keyed by list key and contains an entry **only** for a
|
|
42
|
+
* list that:
|
|
43
|
+
*
|
|
44
|
+
* 1. opts in (`ui.navCount === true`) — no count query runs for any other list,
|
|
45
|
+
* 2. is not a singleton (a single-record list has no meaningful count), and
|
|
46
|
+
* 3. does not have statically-denied query access (see
|
|
47
|
+
* {@link isListQueryStaticallyDenied}).
|
|
48
|
+
*
|
|
49
|
+
* Each count is read through the secured `context.db`, whose `count` applies the
|
|
50
|
+
* access filter, so the number reflects exactly what the current session may
|
|
51
|
+
* see (a denied row is not counted). Counts are fetched concurrently.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveNavCounts(
|
|
54
|
+
context: AccessContext,
|
|
55
|
+
config: OpenSaasConfig,
|
|
56
|
+
): Promise<Record<string, number>> {
|
|
57
|
+
const counts: Record<string, number> = {}
|
|
58
|
+
const lists = config.lists ?? {}
|
|
59
|
+
|
|
60
|
+
const optedIn = Object.keys(lists).filter((listKey) => {
|
|
61
|
+
const listConfig = lists[listKey] as ListConfig<TypeInfo> | undefined
|
|
62
|
+
if (!listConfig) return false
|
|
63
|
+
if (listConfig.ui?.navCount !== true) return false
|
|
64
|
+
if (listConfig.isSingleton) return false
|
|
65
|
+
return !isListQueryStaticallyDenied(listConfig)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await Promise.all(
|
|
69
|
+
optedIn.map(async (listKey) => {
|
|
70
|
+
const delegate: CountDelegate | undefined = context.db?.[getDbKey(listKey)]
|
|
71
|
+
if (!delegate?.count) return
|
|
72
|
+
// A single list's count failing (a DB hiccup, a throwing access filter or
|
|
73
|
+
// hook) must not blank the whole admin chrome — `AdminUI` awaits this
|
|
74
|
+
// before rendering the shell. Degrade like the sibling `ListView`: log and
|
|
75
|
+
// omit just that badge. Other lists' counts are unaffected.
|
|
76
|
+
try {
|
|
77
|
+
counts[listKey] = await delegate.count()
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error(`Failed to resolve nav count for ${listKey}:`, error)
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return counts
|
|
85
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccessControl, FieldAccess } from '../access/types.js'
|
|
2
|
+
import type { FilterSpec } from '../filter/types.js'
|
|
2
3
|
import type { z } from 'zod'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -663,6 +664,29 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
|
|
|
663
664
|
type: string
|
|
664
665
|
optional: boolean
|
|
665
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* Declare this field's filtering capability — its {@link FilterSpec} — for the
|
|
669
|
+
* admin UI's Filter builder (ADR-0017). Optional and additive: a field that
|
|
670
|
+
* omits it (like `password`, `json`, `virtual`, or a third-party field that
|
|
671
|
+
* hasn't adopted filtering) is simply not filterable and never suggested, so
|
|
672
|
+
* absence degrades gracefully everywhere.
|
|
673
|
+
*
|
|
674
|
+
* Self-contained, like {@link getPrismaType} and friends — the filter engine
|
|
675
|
+
* delegates to each field's spec rather than switching on field type. The
|
|
676
|
+
* returned `toCondition` mapper must stay pure (no DB/Prisma imports); its
|
|
677
|
+
* output is ANDed with the access filter through the secured context.
|
|
678
|
+
*
|
|
679
|
+
* @param fieldName The field's config key (closed over by the mapper).
|
|
680
|
+
* @param listKey The owning list's key.
|
|
681
|
+
* @param config The full config (e.g. relationship specs resolve their
|
|
682
|
+
* target list's label field from it).
|
|
683
|
+
* @returns The field's Filter spec, or `undefined` when not filterable.
|
|
684
|
+
*/
|
|
685
|
+
getFilterSpec?: (
|
|
686
|
+
fieldName: string,
|
|
687
|
+
listKey: string,
|
|
688
|
+
config: OpenSaasConfig,
|
|
689
|
+
) => FilterSpec | undefined
|
|
666
690
|
/**
|
|
667
691
|
* Get TypeScript imports needed for this field's type
|
|
668
692
|
* @returns Array of import statements needed for the generated types file
|
|
@@ -814,9 +838,34 @@ export type PasswordField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConf
|
|
|
814
838
|
}
|
|
815
839
|
}
|
|
816
840
|
|
|
841
|
+
/**
|
|
842
|
+
* Badge-variant vocabulary a select option can map its value to for status-Cell
|
|
843
|
+
* rendering in the admin UI (issue #729). Mirrors the admin UI Badge primitive's
|
|
844
|
+
* variants; `secondary` is the neutral fallback used for unmapped options.
|
|
845
|
+
*/
|
|
846
|
+
export type SelectOptionVariant =
|
|
847
|
+
'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* A single choice in a `select` field.
|
|
851
|
+
*
|
|
852
|
+
* `label`/`value` are the long-standing shape; `ui` is additive, optional
|
|
853
|
+
* metadata (issue #729). `ui.variant` lets a value render as a coloured status
|
|
854
|
+
* badge in list-table Cells — an option without it renders with the neutral
|
|
855
|
+
* badge, so existing options keep working unchanged.
|
|
856
|
+
*/
|
|
857
|
+
export type SelectOption = {
|
|
858
|
+
label: string
|
|
859
|
+
value: string
|
|
860
|
+
ui?: {
|
|
861
|
+
/** Badge variant used when this option's value renders as a status Cell. */
|
|
862
|
+
variant?: SelectOptionVariant
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
817
866
|
export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
|
|
818
867
|
type: 'select'
|
|
819
|
-
options: Array<
|
|
868
|
+
options: Array<SelectOption>
|
|
820
869
|
defaultValue?: string
|
|
821
870
|
db?: {
|
|
822
871
|
/**
|
|
@@ -880,6 +929,93 @@ export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig
|
|
|
880
929
|
}
|
|
881
930
|
}
|
|
882
931
|
|
|
932
|
+
/**
|
|
933
|
+
* Item-view configuration for a single to-many relationship field — the
|
|
934
|
+
* per-relationship overrides for the admin item view's Relationship table
|
|
935
|
+
* (issue #734). All values are plain, serialisable data so they can cross the
|
|
936
|
+
* server→client boundary via the existing field-config serialisation.
|
|
937
|
+
*/
|
|
938
|
+
export type RelationshipItemViewConfig = {
|
|
939
|
+
/**
|
|
940
|
+
* How this to-many relationship is presented on the owning record's item
|
|
941
|
+
* view:
|
|
942
|
+
* - `'table'` (default): a read-only Relationship table of related rows.
|
|
943
|
+
* - `'picker'`: demote it back to the compact relationship picker inside the
|
|
944
|
+
* details card (the pre-#734 behaviour).
|
|
945
|
+
*
|
|
946
|
+
* @default 'table'
|
|
947
|
+
*/
|
|
948
|
+
displayMode?: 'table' | 'picker'
|
|
949
|
+
/**
|
|
950
|
+
* The related list's fields to show as Relationship-table columns, in order.
|
|
951
|
+
*
|
|
952
|
+
* When omitted, the columns default to the related list's own column
|
|
953
|
+
* curation (`ui.listView.initialColumns`, else all non-system fields) minus
|
|
954
|
+
* the back-reference field that points at the parent record.
|
|
955
|
+
*
|
|
956
|
+
* @example
|
|
957
|
+
* ```typescript
|
|
958
|
+
* posts: relationship({
|
|
959
|
+
* ref: 'Post.author',
|
|
960
|
+
* many: true,
|
|
961
|
+
* ui: { itemView: { columns: ['title', 'status'] } },
|
|
962
|
+
* })
|
|
963
|
+
* ```
|
|
964
|
+
*/
|
|
965
|
+
columns?: string[]
|
|
966
|
+
/**
|
|
967
|
+
* The maximum number of related rows to fetch and render in this
|
|
968
|
+
* Relationship table (issue #752). The item view fetches related rows
|
|
969
|
+
* through the secured context bounded by this `take`, so a record with many
|
|
970
|
+
* related rows never loads/renders every one on edit-page open. The totals
|
|
971
|
+
* footer still shows the full access-scoped total ("showing N of M").
|
|
972
|
+
*
|
|
973
|
+
* When omitted, a sensible default cap applies (the UI's
|
|
974
|
+
* `DEFAULT_ITEM_VIEW_TAKE`). Must be a positive integer; non-positive or
|
|
975
|
+
* non-integer values fall back to the default.
|
|
976
|
+
*
|
|
977
|
+
* @example
|
|
978
|
+
* ```typescript
|
|
979
|
+
* sessions: relationship({
|
|
980
|
+
* ref: 'Session.user',
|
|
981
|
+
* many: true,
|
|
982
|
+
* ui: { itemView: { take: 5 } },
|
|
983
|
+
* })
|
|
984
|
+
* ```
|
|
985
|
+
*/
|
|
986
|
+
take?: number
|
|
987
|
+
/**
|
|
988
|
+
* The numeric columns whose values are summed in the Relationship table's
|
|
989
|
+
* totals footer. The row count is always shown; sums appear only for the
|
|
990
|
+
* columns listed here, each formatted by that column's Cell.
|
|
991
|
+
*
|
|
992
|
+
* @example
|
|
993
|
+
* ```typescript
|
|
994
|
+
* lineItems: relationship({
|
|
995
|
+
* ref: 'LineItem.order',
|
|
996
|
+
* many: true,
|
|
997
|
+
* ui: { itemView: { sum: ['amount'] } },
|
|
998
|
+
* })
|
|
999
|
+
* ```
|
|
1000
|
+
*/
|
|
1001
|
+
sum?: string[]
|
|
1002
|
+
/**
|
|
1003
|
+
* How a row's ✕ control removes a related row from this table (ADR-0018).
|
|
1004
|
+
*
|
|
1005
|
+
* - `'disconnect'` (default): non-destructively unlinks the related row from
|
|
1006
|
+
* this record — the row itself is untouched and still appears on its own
|
|
1007
|
+
* list. Gated on the related list's update access. Hidden statically when
|
|
1008
|
+
* the schema makes disconnect impossible (a required foreign key on the
|
|
1009
|
+
* related side, i.e. the back-reference declares `db.isNullable: false`).
|
|
1010
|
+
* - `'delete'`: opts into truly deleting the related row, behind a
|
|
1011
|
+
* confirmation, gated on the related list's delete access.
|
|
1012
|
+
* - `'none'`: hides the removal control entirely.
|
|
1013
|
+
*
|
|
1014
|
+
* @default 'disconnect'
|
|
1015
|
+
*/
|
|
1016
|
+
removeAction?: 'disconnect' | 'delete' | 'none'
|
|
1017
|
+
}
|
|
1018
|
+
|
|
883
1019
|
export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
|
|
884
1020
|
BaseFieldConfig<TTypeInfo> & {
|
|
885
1021
|
type: 'relationship'
|
|
@@ -1006,6 +1142,13 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
|
|
|
1006
1142
|
}
|
|
1007
1143
|
ui?: {
|
|
1008
1144
|
displayMode?: 'select' | 'cards'
|
|
1145
|
+
/**
|
|
1146
|
+
* Item-view (Relationship table) overrides for this to-many relationship
|
|
1147
|
+
* — columns, summed columns, and demotion to the compact picker (issue
|
|
1148
|
+
* #734). Ignored for single (`many: false`) relationships, which always
|
|
1149
|
+
* render inside the details card.
|
|
1150
|
+
*/
|
|
1151
|
+
itemView?: RelationshipItemViewConfig
|
|
1009
1152
|
}
|
|
1010
1153
|
/**
|
|
1011
1154
|
* Get the complete Prisma schema contribution for this relationship field.
|
|
@@ -1731,6 +1874,13 @@ export type ListUIConfig = {
|
|
|
1731
1874
|
* Keystone's `ui.listView`.
|
|
1732
1875
|
*/
|
|
1733
1876
|
listView?: ListViewUIConfig
|
|
1877
|
+
/**
|
|
1878
|
+
* Item-view (record edit page) configuration for this list — controls the
|
|
1879
|
+
* placement/order of the shape-derived Relationship-table sections (issue
|
|
1880
|
+
* #734). When omitted, sections appear in field-declaration order and the
|
|
1881
|
+
* layout is derived purely from the number of to-many relationships.
|
|
1882
|
+
*/
|
|
1883
|
+
itemView?: ItemViewUIConfig
|
|
1734
1884
|
/**
|
|
1735
1885
|
* The field used to represent a row as a single label — in relationship
|
|
1736
1886
|
* cells, dropdown options, and page headings. Must reference a declared,
|
|
@@ -1745,6 +1895,141 @@ export type ListUIConfig = {
|
|
|
1745
1895
|
* ```
|
|
1746
1896
|
*/
|
|
1747
1897
|
labelField?: string
|
|
1898
|
+
/**
|
|
1899
|
+
* Opt this list into an access-scoped record count next to its Admin chrome
|
|
1900
|
+
* nav item (issue #735). Default `false` — no count query runs for lists that
|
|
1901
|
+
* don't opt in. The count is fetched through the secured context, so it only
|
|
1902
|
+
* ever reflects what the current session may see; a list whose query access
|
|
1903
|
+
* is statically denied renders no count rather than a misleading zero.
|
|
1904
|
+
*
|
|
1905
|
+
* @example
|
|
1906
|
+
* ```typescript
|
|
1907
|
+
* ui: { navCount: true }
|
|
1908
|
+
* ```
|
|
1909
|
+
*/
|
|
1910
|
+
navCount?: boolean
|
|
1911
|
+
/**
|
|
1912
|
+
* Opt this list's label column into an initials-avatar Cell (issue #735) — an
|
|
1913
|
+
* initials bubble whose text and colour derive deterministically from the row,
|
|
1914
|
+
* rendered ahead of the emphasized {@link getItemLabel Item label}. Default
|
|
1915
|
+
* `false` (text-only label). A per-field cell override (`ui.cell`) on the
|
|
1916
|
+
* label field still wins.
|
|
1917
|
+
*
|
|
1918
|
+
* @example
|
|
1919
|
+
* ```typescript
|
|
1920
|
+
* ui: { avatar: true }
|
|
1921
|
+
* ```
|
|
1922
|
+
*/
|
|
1923
|
+
avatar?: boolean
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Item-view (record edit page) configuration, mirroring the `ui.listView`
|
|
1928
|
+
* shape but for the shape-derived item layout (issue #734).
|
|
1929
|
+
*
|
|
1930
|
+
* The layout itself — single card / two-column split / stacked — is derived
|
|
1931
|
+
* from the number of to-many relationships rendered as Relationship tables and
|
|
1932
|
+
* needs no configuration. This block only reorders those sections; the
|
|
1933
|
+
* per-relationship column/sum/picker overrides live on the relationship field
|
|
1934
|
+
* (`ui.itemView`, see {@link RelationshipItemViewConfig}).
|
|
1935
|
+
*/
|
|
1936
|
+
export type ItemViewUIConfig = {
|
|
1937
|
+
/**
|
|
1938
|
+
* The order of the Relationship-table sections, by their to-many
|
|
1939
|
+
* relationship field name. Listed fields come first in this order; any
|
|
1940
|
+
* to-many relationship not listed keeps its declaration order after them.
|
|
1941
|
+
*
|
|
1942
|
+
* @example
|
|
1943
|
+
* ```typescript
|
|
1944
|
+
* ui: { itemView: { order: ['orders', 'reviews'] } }
|
|
1945
|
+
* ```
|
|
1946
|
+
*/
|
|
1947
|
+
order?: string[]
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* A button variant for a custom Bulk action, matching the admin UI's Button
|
|
1952
|
+
* variants. Serializable — it crosses to the client selection bar as a plain
|
|
1953
|
+
* string.
|
|
1954
|
+
*/
|
|
1955
|
+
export type BulkActionVariant =
|
|
1956
|
+
'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* Arguments passed to a custom Bulk action's server-side `handler` (issue #736).
|
|
1960
|
+
*
|
|
1961
|
+
* The handler runs entirely server-side — it is NEVER serialized to the client —
|
|
1962
|
+
* and must do all of its work through the SECURED `context` (never raw Prisma),
|
|
1963
|
+
* so per-row access control still applies and a denied row is a Silent failure.
|
|
1964
|
+
*/
|
|
1965
|
+
export interface BulkActionContext {
|
|
1966
|
+
/** The list the action was invoked on (PascalCase list key). */
|
|
1967
|
+
listKey: string
|
|
1968
|
+
/** The explicit set of selected row ids the admin chose. */
|
|
1969
|
+
ids: string[]
|
|
1970
|
+
/**
|
|
1971
|
+
* The access-scoped context. All database work MUST go through `context.db`
|
|
1972
|
+
* so the list's access control and hooks are enforced for every id.
|
|
1973
|
+
*/
|
|
1974
|
+
context: import('../access/types.js').AccessContext
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* The outcome a Bulk action `handler` returns. The optional `message` is shown
|
|
1979
|
+
* in the selection bar's status line once the action completes; returning
|
|
1980
|
+
* nothing yields a generic completion message.
|
|
1981
|
+
*/
|
|
1982
|
+
export interface BulkActionResult {
|
|
1983
|
+
/** Human-readable outcome shown to the admin (e.g. "Published 3 of 5"). */
|
|
1984
|
+
message?: string
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/**
|
|
1988
|
+
* A list-specific custom Bulk action declared in `ui.listView.bulkActions`
|
|
1989
|
+
* (issue #736). Rendered as a button in the list view's selection bar, in
|
|
1990
|
+
* declaration order, alongside the built-in Delete.
|
|
1991
|
+
*
|
|
1992
|
+
* SERIALISATION BOUNDARY: only `key`, `label`, `variant` and `destructive`
|
|
1993
|
+
* cross to the client (as plain data). `handler` and `hasAccess` are
|
|
1994
|
+
* server-side functions and never leave the server — the client sends `key`
|
|
1995
|
+
* back through the generic server action, which looks the handler up and runs
|
|
1996
|
+
* it with the freshly-rebuilt secured context.
|
|
1997
|
+
*/
|
|
1998
|
+
export interface BulkAction {
|
|
1999
|
+
/**
|
|
2000
|
+
* Stable identifier the client echoes back to dispatch this action
|
|
2001
|
+
* server-side. Must be unique within the list.
|
|
2002
|
+
*/
|
|
2003
|
+
key: string
|
|
2004
|
+
/** Button label shown in the selection bar. */
|
|
2005
|
+
label: string
|
|
2006
|
+
/** Button variant (defaults to `'outline'`). */
|
|
2007
|
+
variant?: BulkActionVariant
|
|
2008
|
+
/**
|
|
2009
|
+
* When true, the admin is asked to confirm before the action runs, reusing
|
|
2010
|
+
* the same confirmation affordance as the built-in Delete.
|
|
2011
|
+
*/
|
|
2012
|
+
destructive?: boolean
|
|
2013
|
+
/**
|
|
2014
|
+
* Optional visibility gate, evaluated server-side against the session before
|
|
2015
|
+
* the button is offered (user story 14 — "only see actions I may perform").
|
|
2016
|
+
* Returning `false` hides the button.
|
|
2017
|
+
*
|
|
2018
|
+
* This ONLY controls visibility; the `handler` always runs through the
|
|
2019
|
+
* secured context, so per-row access is enforced regardless. It is also
|
|
2020
|
+
* re-checked server-side on dispatch, so a hidden action cannot be invoked.
|
|
2021
|
+
*/
|
|
2022
|
+
hasAccess?: (args: {
|
|
2023
|
+
session: import('../access/types.js').Session | null
|
|
2024
|
+
context: import('../access/types.js').AccessContext
|
|
2025
|
+
listKey: string
|
|
2026
|
+
}) => boolean | Promise<boolean>
|
|
2027
|
+
/**
|
|
2028
|
+
* The server-side handler. Receives the selected ids and the secured
|
|
2029
|
+
* `context`; do all work through `context.db`. Returns an optional
|
|
2030
|
+
* `{ message }` shown in the selection bar when the action completes.
|
|
2031
|
+
*/
|
|
2032
|
+
handler: (args: BulkActionContext) => BulkActionResult | void | Promise<BulkActionResult | void>
|
|
1748
2033
|
}
|
|
1749
2034
|
|
|
1750
2035
|
/**
|
|
@@ -1783,6 +2068,39 @@ export type ListViewUIConfig = {
|
|
|
1783
2068
|
/** The sort direction. */
|
|
1784
2069
|
direction: 'asc' | 'desc'
|
|
1785
2070
|
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Custom list-specific Bulk actions shown in the list view's selection bar,
|
|
2073
|
+
* in declaration order, alongside the built-in Delete (issue #736). Each
|
|
2074
|
+
* action's `handler` runs server-side over the selected ids through the
|
|
2075
|
+
* secured context; only serializable metadata (`key`/`label`/`variant`/
|
|
2076
|
+
* `destructive`) crosses to the client.
|
|
2077
|
+
*
|
|
2078
|
+
* @example
|
|
2079
|
+
* ```typescript
|
|
2080
|
+
* ui: {
|
|
2081
|
+
* listView: {
|
|
2082
|
+
* bulkActions: [
|
|
2083
|
+
* {
|
|
2084
|
+
* key: 'publish',
|
|
2085
|
+
* label: 'Publish',
|
|
2086
|
+
* handler: async ({ ids, context }) => {
|
|
2087
|
+
* let n = 0
|
|
2088
|
+
* for (const id of ids) {
|
|
2089
|
+
* const r = await context.db.post.update({
|
|
2090
|
+
* where: { id },
|
|
2091
|
+
* data: { status: 'published' },
|
|
2092
|
+
* })
|
|
2093
|
+
* if (r) n++
|
|
2094
|
+
* }
|
|
2095
|
+
* return { message: `Published ${n} of ${ids.length}` }
|
|
2096
|
+
* },
|
|
2097
|
+
* },
|
|
2098
|
+
* ],
|
|
2099
|
+
* },
|
|
2100
|
+
* }
|
|
2101
|
+
* ```
|
|
2102
|
+
*/
|
|
2103
|
+
bulkActions?: BulkAction[]
|
|
1786
2104
|
}
|
|
1787
2105
|
|
|
1788
2106
|
/**
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
executeFieldResolveInputHooks,
|
|
7
7
|
executeFieldValidateHooks,
|
|
8
8
|
validateFieldRules,
|
|
9
|
+
splitMultiColumnFields,
|
|
9
10
|
ValidationError,
|
|
10
11
|
} from '../hooks/index.js'
|
|
11
12
|
import { applyCreateDefaults } from './apply-defaults.js'
|
|
@@ -66,12 +67,17 @@ export interface HookPipeline {
|
|
|
66
67
|
* → list `validate`
|
|
67
68
|
* → field `validate`
|
|
68
69
|
* → built-in field rules (`validateFieldRules`)
|
|
70
|
+
* → split multi-column fields (`splitMultiColumnFields`)
|
|
69
71
|
*
|
|
70
72
|
* Contract preserved exactly:
|
|
71
73
|
* - `resolvedData` starts as `inputData` and is threaded through each phase;
|
|
72
74
|
* - validate hooks report failures via `addValidationError` → THROW
|
|
73
75
|
* `ValidationError` (never silent);
|
|
74
76
|
* - built-in field rule failures THROW `ValidationError`;
|
|
77
|
+
* - a multi-column field (e.g. storage image()/file() in Keystone-parity
|
|
78
|
+
* mode) is validated under its LOGICAL key BEFORE it is split into
|
|
79
|
+
* physical columns (#789) — an unrecognised value throws instead of being
|
|
80
|
+
* silently split into null/undefined columns;
|
|
75
81
|
* - on success returns the transformed `resolvedData`.
|
|
76
82
|
*/
|
|
77
83
|
async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResult> {
|
|
@@ -160,6 +166,19 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
|
|
|
160
166
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
161
167
|
}
|
|
162
168
|
|
|
169
|
+
// ── Phase 4: split multi-column fields into physical columns ──────────────
|
|
170
|
+
// Only reached once the logical value has passed validation (#789). Replaces
|
|
171
|
+
// each multi-column field's logical key with its per-part columns, gated by
|
|
172
|
+
// the field's own write access (see `splitMultiColumnFields`).
|
|
173
|
+
resolvedData = await splitMultiColumnFields(
|
|
174
|
+
inputData,
|
|
175
|
+
resolvedData,
|
|
176
|
+
listConfig.fields,
|
|
177
|
+
operation,
|
|
178
|
+
context,
|
|
179
|
+
item,
|
|
180
|
+
)
|
|
181
|
+
|
|
163
182
|
return { resolvedData }
|
|
164
183
|
}
|
|
165
184
|
|