@cosmicdrift/kumiko-framework 0.224.2 → 0.225.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.225.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.225.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.225.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -4099,7 +4099,7 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
4099
4099
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
4100
4100
|
});
|
|
4101
4101
|
|
|
4102
|
-
test("navigate with params targeting a dashboard screen → Throw", () => {
|
|
4102
|
+
test("navigate with params targeting a dashboard screen without a filter → Throw (params would be a no-op)", () => {
|
|
4103
4103
|
const feature = defineFeature("shop", (r) => {
|
|
4104
4104
|
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
4105
4105
|
r.screen({
|
|
@@ -4135,7 +4135,97 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
4135
4135
|
});
|
|
4136
4136
|
});
|
|
4137
4137
|
expect(() => validateBoot([feature])).toThrow(
|
|
4138
|
-
/sets params on navigate-target "product-dashboard"
|
|
4138
|
+
/sets params on navigate-target "product-dashboard" \(dashboard\).*declares no filter.*no-op/,
|
|
4139
|
+
);
|
|
4140
|
+
});
|
|
4141
|
+
|
|
4142
|
+
test("navigate with params targeting a dashboard screen with a matching filter → no throw (useFilterParams reads it, fw#1708 follow-up)", () => {
|
|
4143
|
+
const feature = defineFeature("shop", (r) => {
|
|
4144
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
4145
|
+
r.screen({
|
|
4146
|
+
id: "product-list",
|
|
4147
|
+
type: "entityList",
|
|
4148
|
+
entity: "product",
|
|
4149
|
+
columns: ["name"],
|
|
4150
|
+
rowActions: [
|
|
4151
|
+
{
|
|
4152
|
+
kind: "navigate",
|
|
4153
|
+
id: "view",
|
|
4154
|
+
label: "actions.view",
|
|
4155
|
+
screen: "product-dashboard",
|
|
4156
|
+
params: { map: { category: "name" } },
|
|
4157
|
+
},
|
|
4158
|
+
],
|
|
4159
|
+
});
|
|
4160
|
+
r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
|
|
4161
|
+
access: { openToAll: true },
|
|
4162
|
+
});
|
|
4163
|
+
r.screen({
|
|
4164
|
+
id: "product-dashboard",
|
|
4165
|
+
type: "dashboard",
|
|
4166
|
+
filter: {
|
|
4167
|
+
id: "category",
|
|
4168
|
+
label: "Category",
|
|
4169
|
+
kind: "select",
|
|
4170
|
+
options: [{ value: "a", label: "A" }],
|
|
4171
|
+
},
|
|
4172
|
+
panels: [
|
|
4173
|
+
{
|
|
4174
|
+
kind: "stat",
|
|
4175
|
+
id: "count",
|
|
4176
|
+
label: "Products",
|
|
4177
|
+
query: "shop:query:count",
|
|
4178
|
+
valueField: "total",
|
|
4179
|
+
},
|
|
4180
|
+
],
|
|
4181
|
+
});
|
|
4182
|
+
});
|
|
4183
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
4184
|
+
});
|
|
4185
|
+
|
|
4186
|
+
test("navigate with params targeting a dashboard screen whose filter id doesn't match any extracted key → Throw", () => {
|
|
4187
|
+
const feature = defineFeature("shop", (r) => {
|
|
4188
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
4189
|
+
r.screen({
|
|
4190
|
+
id: "product-list",
|
|
4191
|
+
type: "entityList",
|
|
4192
|
+
entity: "product",
|
|
4193
|
+
columns: ["name"],
|
|
4194
|
+
rowActions: [
|
|
4195
|
+
{
|
|
4196
|
+
kind: "navigate",
|
|
4197
|
+
id: "view",
|
|
4198
|
+
label: "actions.view",
|
|
4199
|
+
screen: "product-dashboard",
|
|
4200
|
+
params: { map: { productName: "name" } },
|
|
4201
|
+
},
|
|
4202
|
+
],
|
|
4203
|
+
});
|
|
4204
|
+
r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
|
|
4205
|
+
access: { openToAll: true },
|
|
4206
|
+
});
|
|
4207
|
+
r.screen({
|
|
4208
|
+
id: "product-dashboard",
|
|
4209
|
+
type: "dashboard",
|
|
4210
|
+
filter: {
|
|
4211
|
+
id: "category",
|
|
4212
|
+
label: "Category",
|
|
4213
|
+
kind: "select",
|
|
4214
|
+
options: [{ value: "a", label: "A" }],
|
|
4215
|
+
},
|
|
4216
|
+
panels: [
|
|
4217
|
+
{
|
|
4218
|
+
kind: "stat",
|
|
4219
|
+
id: "count",
|
|
4220
|
+
label: "Products",
|
|
4221
|
+
query: "shop:query:count",
|
|
4222
|
+
valueField: "total",
|
|
4223
|
+
},
|
|
4224
|
+
],
|
|
4225
|
+
});
|
|
4226
|
+
});
|
|
4227
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
4228
|
+
/sets params \[productName\] on navigate-target "product-dashboard" \(dashboard\) whose filter id is "category".*none of the extracted keys match/,
|
|
4139
4229
|
);
|
|
4140
4230
|
});
|
|
4141
4231
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Akten-Layout: projectionDetail `layout.mode: "tabs"` + `header`/`metrics`.
|
|
13
|
+
describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
14
|
+
test("mode: tabs with only one section throws", () => {
|
|
15
|
+
const feature = defineFeature("app", (r) => {
|
|
16
|
+
r.screen({
|
|
17
|
+
id: "rent-detail",
|
|
18
|
+
type: "projectionDetail",
|
|
19
|
+
query: "app:query:rent:detail",
|
|
20
|
+
layout: {
|
|
21
|
+
mode: "tabs",
|
|
22
|
+
sections: [{ id: "overview", title: "Overview", fields: ["description"] }],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
expect(() => validateBoot([feature])).toThrow(/tabs need at least 2 sections/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("tabs section without a title throws", () => {
|
|
30
|
+
const feature = defineFeature("app", (r) => {
|
|
31
|
+
r.screen({
|
|
32
|
+
id: "rent-detail",
|
|
33
|
+
type: "projectionDetail",
|
|
34
|
+
query: "app:query:rent:detail",
|
|
35
|
+
layout: {
|
|
36
|
+
mode: "tabs",
|
|
37
|
+
sections: [
|
|
38
|
+
{ id: "overview", fields: ["description"] },
|
|
39
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
expect(() => validateBoot([feature])).toThrow(/sections\[0\] has no title/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("tabs section without an id throws", () => {
|
|
48
|
+
const feature = defineFeature("app", (r) => {
|
|
49
|
+
r.screen({
|
|
50
|
+
id: "rent-detail",
|
|
51
|
+
type: "projectionDetail",
|
|
52
|
+
query: "app:query:rent:detail",
|
|
53
|
+
layout: {
|
|
54
|
+
mode: "tabs",
|
|
55
|
+
sections: [
|
|
56
|
+
{ title: "Overview", fields: ["description"] },
|
|
57
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
expect(() => validateBoot([feature])).toThrow(/sections\[0\] \("Overview"\) has no id/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("tabs section id that is not kebab-case throws", () => {
|
|
66
|
+
const feature = defineFeature("app", (r) => {
|
|
67
|
+
r.screen({
|
|
68
|
+
id: "rent-detail",
|
|
69
|
+
type: "projectionDetail",
|
|
70
|
+
query: "app:query:rent:detail",
|
|
71
|
+
layout: {
|
|
72
|
+
mode: "tabs",
|
|
73
|
+
sections: [
|
|
74
|
+
{ id: "Overview", title: "Overview", fields: ["description"] },
|
|
75
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
expect(() => validateBoot([feature])).toThrow(/must be kebab-case/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("duplicate tab ids throw", () => {
|
|
84
|
+
const feature = defineFeature("app", (r) => {
|
|
85
|
+
r.screen({
|
|
86
|
+
id: "rent-detail",
|
|
87
|
+
type: "projectionDetail",
|
|
88
|
+
query: "app:query:rent:detail",
|
|
89
|
+
layout: {
|
|
90
|
+
mode: "tabs",
|
|
91
|
+
sections: [
|
|
92
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
93
|
+
{ id: "overview", title: "History", fields: ["notes"] },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
expect(() => validateBoot([feature])).toThrow(/duplicate tab id "overview"/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("mode: tabs on entityEdit throws — tabs are projectionDetail-only", () => {
|
|
102
|
+
const feature = defineFeature("app", (r) => {
|
|
103
|
+
r.entity("rent", createEntity({ fields: { name: createTextField() } }));
|
|
104
|
+
r.screen({
|
|
105
|
+
id: "rent-edit",
|
|
106
|
+
type: "entityEdit",
|
|
107
|
+
entity: "rent",
|
|
108
|
+
layout: {
|
|
109
|
+
mode: "tabs",
|
|
110
|
+
sections: [
|
|
111
|
+
{ id: "overview", title: "Overview", columns: 1, fields: ["name"] },
|
|
112
|
+
{ id: "history", title: "History", columns: 1, fields: ["name"] },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
118
|
+
/Screen "rent-edit" \(entityEdit\) sets mode: "tabs" — tabs are only supported on projectionDetail/,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("metric without a fieldLabels entry throws — no fallback to the raw column name", () => {
|
|
123
|
+
const feature = defineFeature("app", (r) => {
|
|
124
|
+
r.screen({
|
|
125
|
+
id: "rent-detail",
|
|
126
|
+
type: "projectionDetail",
|
|
127
|
+
query: "app:query:rent:detail",
|
|
128
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
129
|
+
metrics: ["balance"],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("valid tabs + header + metrics declaration boots cleanly", () => {
|
|
136
|
+
const feature = defineFeature("app", (r) => {
|
|
137
|
+
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
138
|
+
access: { openToAll: true },
|
|
139
|
+
});
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "rent-detail",
|
|
142
|
+
type: "projectionDetail",
|
|
143
|
+
query: "app:query:rent:detail",
|
|
144
|
+
header: { title: "name", subtitle: "address", status: "state" },
|
|
145
|
+
metrics: ["balance", "overdueDays"],
|
|
146
|
+
fieldLabels: { balance: "rent.balance", overdueDays: "rent.overdueDays" },
|
|
147
|
+
layout: {
|
|
148
|
+
mode: "tabs",
|
|
149
|
+
sections: [
|
|
150
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
151
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
9
|
-
import { isValidQn, qualifyEntityName } from "../qualified-name";
|
|
9
|
+
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
|
|
10
10
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
11
11
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
12
12
|
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
@@ -83,6 +83,14 @@ function validateNoWidgetRequiredField(
|
|
|
83
83
|
// entityList and projectionList (framework#1708) — projectionList has no
|
|
84
84
|
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
85
85
|
// entityEdit target without an explicit entityId reaches create there.
|
|
86
|
+
//
|
|
87
|
+
// dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
|
|
88
|
+
// URL search params — but only for the single `filter.id` a dashboard
|
|
89
|
+
// declares (useFilterParams in dashboard-body.tsx seeds its value from
|
|
90
|
+
// `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
|
|
91
|
+
// for the value to land, and a params extractor whose keys don't include
|
|
92
|
+
// the filter's id would silently miss it — both are boot errors instead of
|
|
93
|
+
// a silently-empty dashboard.
|
|
86
94
|
function validateRowActionNavigateParams(
|
|
87
95
|
featureName: string,
|
|
88
96
|
screenId: string,
|
|
@@ -93,16 +101,38 @@ function validateRowActionNavigateParams(
|
|
|
93
101
|
): void {
|
|
94
102
|
// skip: not a navigate-with-params action — nothing to validate here.
|
|
95
103
|
if (action.kind !== "navigate" || action.params === undefined) return;
|
|
96
|
-
//
|
|
104
|
+
// skip: unresolvable/custom target already reported (or exempt) elsewhere.
|
|
105
|
+
if (target === undefined || target.screen.type === "custom") return;
|
|
106
|
+
// skip: entityList/projectionList targets also read URL search params (Tier
|
|
97
107
|
// 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
|
|
98
108
|
// .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
if (target.screen.type === "entityList" || target.screen.type === "projectionList") return;
|
|
110
|
+
|
|
111
|
+
if (target.screen.type === "dashboard") {
|
|
112
|
+
const targetDescriptor =
|
|
113
|
+
action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
114
|
+
const filter = target.screen.filter;
|
|
115
|
+
if (filter === undefined) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
|
|
118
|
+
`params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
|
|
119
|
+
`filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
|
|
120
|
+
`target dashboard so it has somewhere to read the value from.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const extractedKeys =
|
|
124
|
+
"pick" in action.params ? action.params.pick : Object.keys(action.params.map);
|
|
125
|
+
if (!extractedKeys.includes(filter.id)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
|
|
128
|
+
`params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
|
|
129
|
+
`filter id is "${filter.id}" — none of the extracted keys match, so the filter would stay ` +
|
|
130
|
+
`unset. Fix the params extractor to produce a "${filter.id}" key, or remove it.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
// skip: filter present and the extractor's keys cover it — valid dashboard deep-link.
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
106
136
|
|
|
107
137
|
const isEntityEditUpdate =
|
|
108
138
|
target.screen.type === "entityEdit" &&
|
|
@@ -140,6 +170,17 @@ function validateWizardLayout(
|
|
|
140
170
|
layout: EditLayout,
|
|
141
171
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
142
172
|
): void {
|
|
173
|
+
// Tabs truncate the layout to one section, but scopeFieldNames derives
|
|
174
|
+
// required-field validation from the `fields` prop, not from layout — a
|
|
175
|
+
// hidden tab could hide a required field and silently block submit.
|
|
176
|
+
// projectionDetail has no submit, so tabs are safe there (see its own
|
|
177
|
+
// branch in validateScreens) but not here.
|
|
178
|
+
if (layout.mode === "tabs") {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
|
|
181
|
+
`supported on projectionDetail. Use mode: "wizard" or "single" instead.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
143
184
|
// "form-draft" is hardcoded because the framework layer must not depend on
|
|
144
185
|
// @cosmicdrift/kumiko-bundled-features — same precedence as the
|
|
145
186
|
// "user-data-rights" check in gdpr-storage.ts.
|
|
@@ -572,6 +613,54 @@ export function validateScreens(
|
|
|
572
613
|
`declare at least one section.`,
|
|
573
614
|
);
|
|
574
615
|
}
|
|
616
|
+
if (screen.layout.mode === "tabs") {
|
|
617
|
+
if (screen.layout.sections.length < 2) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but only ` +
|
|
620
|
+
`${screen.layout.sections.length} section(s) — tabs need at least 2 sections.`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
const tabIds = new Set<string>();
|
|
624
|
+
screen.layout.sections.forEach((section, index) => {
|
|
625
|
+
if (section.title === undefined || section.title.trim().length === 0) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
|
|
628
|
+
`sections[${index}] has no title — every tab needs a title.`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (section.id === undefined || section.id.trim().length === 0) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
|
|
634
|
+
`sections[${index}] ("${section.title}") has no id — every tab needs a stable id for ` +
|
|
635
|
+
`the ?tab= param.`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
if (!isKebabSegment(section.id)) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sections[${index}] ` +
|
|
641
|
+
`("${section.title}") has id "${section.id}" — must be kebab-case.`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (tabIds.has(section.id)) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has duplicate tab id ` +
|
|
647
|
+
`"${section.id}" (sections[${index}]).`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
tabIds.add(section.id);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (screen.metrics !== undefined) {
|
|
654
|
+
for (const metric of screen.metrics) {
|
|
655
|
+
if (screen.fieldLabels?.[metric] === undefined) {
|
|
656
|
+
throw new Error(
|
|
657
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${metric}" has ` +
|
|
658
|
+
`no entry in fieldLabels — every metrics field needs a label, there is no fallback to ` +
|
|
659
|
+
`the raw column name.`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
575
664
|
for (const section of screen.layout.sections) {
|
|
576
665
|
if (isExtensionEditSection(section)) {
|
|
577
666
|
throw new Error(
|