@cosmicdrift/kumiko-framework 0.388.0 → 0.389.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/AGENTS.md +3 -0
- package/agents/examples/golden-path.md +13 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- package/agents/examples/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples.md +1 -0
- package/agents/widgets.md +1 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +2 -3
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/routes.d.ts +16 -0
- package/dist/api/routes.js +2 -2
- package/dist/api/server-extra-routes.js +3 -0
- package/dist/api/server-routes.d.ts +1 -1
- package/dist/api/server-routes.js +8 -2
- package/dist/api/server-wiring.js +2 -0
- package/dist/api/sse-route.d.ts +2 -1
- package/dist/api/sse-route.js +11 -1
- package/dist/config/index.d.ts +3 -1
- package/dist/crypto/pg-kms-adapter.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- package/dist/db/aggregate-sql.d.ts +2 -0
- package/dist/db/aggregate-sql.js +10 -0
- package/dist/db/closed-connection.js +12 -19
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- package/dist/db/driver-row-coercion.js +4 -4
- package/dist/db/driver-value-preparation.js +6 -4
- package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.js +36 -5
- package/dist/db/event-store-executor-read-filter.js +6 -3
- package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +0 -3
- package/dist/db/event-store-executor-write-steps.js +0 -5
- package/dist/db/event-store-executor-write.js +15 -15
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +3 -3
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +14 -0
- package/dist/db/query.js +68 -43
- package/dist/db/reference-data.js +14 -2
- package/dist/db/statement-timeout.js +2 -1
- package/dist/db/tenant-db-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +11 -1
- package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
- package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +7 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +4 -0
- package/dist/engine/boot-validator/screens-lists.js +12 -2
- package/dist/engine/build-app-schema.d.ts +2 -1
- package/dist/engine/build-app-schema.js +9 -1
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +4 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +7 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +9 -163
- package/dist/engine/feature-ast/pattern-id-matchers.js +1 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +5 -1
- package/dist/engine/feature-ast/patterns.js +1 -0
- package/dist/engine/feature-ast/render.js +4 -0
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- package/dist/engine/screen-filter-ops.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +7 -3
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +3 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +7 -4
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/create-file-ref-from-bytes.js +3 -0
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +29 -2
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +4 -4
- package/dist/i18n/required-surface-keys.js +7 -2
- package/dist/internal/testing.js +1 -1
- package/dist/jobs/job-runner-scope.js +6 -6
- package/dist/jobs/job-runner.js +3 -0
- package/dist/observability/escape-hatch-report.js +9 -2
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-shared.js +4 -0
- package/dist/pipeline/event-dispatcher-delivery.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- package/dist/pipeline/handler-context-dispatch.d.ts +2 -0
- package/dist/pipeline/handler-context-dispatch.js +13 -1
- package/dist/pipeline/handler-context-events.d.ts +2 -4
- package/dist/pipeline/handler-context-events.js +6 -4
- package/dist/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- package/dist/schema-cli/baseline-command.js +1 -1
- package/dist/schema-cli/status-command.js +1 -1
- package/dist/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +22 -76
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +245 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- package/dist/engine/provider-plugin-context.js +0 -28
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isListFilterAllowed } from "../../db/entity-field-encryption.js";
|
|
2
|
+
import { isFieldFilterable } from "../screen-filter-ops.js";
|
|
3
|
+
import { entityAggregateFieldLookup, validateListTableAggregateFieldTypes, } from "./list-table-aggregate-options.js";
|
|
4
|
+
const RANGE_FACET_TYPES = ["number", "decimal", "money", "bigInt"];
|
|
5
|
+
function validateRangeFacets(where, facets, entity) {
|
|
6
|
+
for (const [field, config] of Object.entries(facets ?? {})) {
|
|
7
|
+
if (config === false || config.display !== "range")
|
|
8
|
+
continue;
|
|
9
|
+
const def = Object.hasOwn(entity.fields, field) ? entity.fields[field] : undefined;
|
|
10
|
+
if (def === undefined || !RANGE_FACET_TYPES.includes(def.type)) {
|
|
11
|
+
throw new Error(`${where} facets.${field}: display "range" needs a number, decimal, money or bigInt field.`);
|
|
12
|
+
}
|
|
13
|
+
if (!isFieldFilterable(def) || !isListFilterAllowed(entity, field, { op: "gte", value: 0 })) {
|
|
14
|
+
throw new Error(`${where} facets.${field}: display "range" needs filterable: true on a field that is not encrypted, personal or secret.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function validateEntityListAggregateOptions(where, screen, entity) {
|
|
19
|
+
if (screen.table !== undefined) {
|
|
20
|
+
validateListTableAggregateFieldTypes(where, screen.table, entityAggregateFieldLookup(entity), true);
|
|
21
|
+
}
|
|
22
|
+
validateRangeFacets(where, screen.facets, entity);
|
|
23
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type RowAction } from "../types/screen.ts";
|
|
2
|
+
/** `where` is the full message prefix, e.g. `[Feature x] Screen "y" (entityList)`. */
|
|
3
|
+
export declare function validateListBulkActions(where: string, bulkActions: unknown, rowActions: readonly RowAction[] | undefined): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { isKebabSegment } from "../qualified-name.js";
|
|
3
|
+
import { LIST_BULK_ACTION_KEYS } from "../types/screen.js";
|
|
4
|
+
function validateBulkActionTarget(where, bulkId, target, rowActions) {
|
|
5
|
+
if (typeof target !== "string" || target === "") {
|
|
6
|
+
throw new Error(`${where} bulk action "${bulkId}" needs rowAction: the id of a row action.`);
|
|
7
|
+
}
|
|
8
|
+
const rowAction = rowActions.find((action) => action.id === target);
|
|
9
|
+
if (rowAction === undefined) {
|
|
10
|
+
const known = rowActions.map((action) => action.id).join(", ") || "(none)";
|
|
11
|
+
throw new Error(`${where} bulk action "${bulkId}" points at rowAction "${target}", which is not declared ` +
|
|
12
|
+
`in rowActions. Declared row actions: ${known}.`);
|
|
13
|
+
}
|
|
14
|
+
if (rowAction.kind !== undefined && rowAction.kind !== "writeHandler") {
|
|
15
|
+
throw new Error(`${where} bulk action "${bulkId}" points at rowAction "${target}" of kind "${rowAction.kind}". ` +
|
|
16
|
+
`Bulk actions run one server write per row, so only writeHandler row actions are allowed.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** `where` is the full message prefix, e.g. `[Feature x] Screen "y" (entityList)`. */
|
|
20
|
+
export function validateListBulkActions(where, bulkActions, rowActions) {
|
|
21
|
+
// skip: no bulk actions declared
|
|
22
|
+
if (bulkActions === undefined)
|
|
23
|
+
return;
|
|
24
|
+
if (!Array.isArray(bulkActions) || bulkActions.length === 0) {
|
|
25
|
+
throw new Error(`${where} bulkActions must be a non-empty array like [{ id: "archive", rowAction: "archive" }].`);
|
|
26
|
+
}
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const entry of bulkActions) {
|
|
29
|
+
if (!isPlainObject(entry)) {
|
|
30
|
+
throw new Error(`${where} bulkActions entries must be objects with id and rowAction.`);
|
|
31
|
+
}
|
|
32
|
+
const id = entry["id"];
|
|
33
|
+
if (typeof id !== "string" || !isKebabSegment(id)) {
|
|
34
|
+
throw new Error(`${where} bulk action id ${JSON.stringify(id)} must be a kebab-case string.`);
|
|
35
|
+
}
|
|
36
|
+
if (seen.has(id))
|
|
37
|
+
throw new Error(`${where} declares bulk action "${id}" twice.`);
|
|
38
|
+
seen.add(id);
|
|
39
|
+
const unknown = Object.keys(entry).filter((key) => !isOneOfBulkKeys(key));
|
|
40
|
+
if (unknown.length > 0) {
|
|
41
|
+
throw new Error(`${where} bulk action "${id}" has unknown key(s) ${unknown.map((k) => `"${k}"`).join(", ")}. ` +
|
|
42
|
+
`Allowed keys: ${LIST_BULK_ACTION_KEYS.join(", ")}.`);
|
|
43
|
+
}
|
|
44
|
+
validateBulkActionTarget(where, id, entry["rowAction"], rowActions ?? []);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isOneOfBulkKeys(key) {
|
|
48
|
+
return LIST_BULK_ACTION_KEYS.some((allowed) => allowed === key);
|
|
49
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ListColumnSpec } from "../types/screen.ts";
|
|
2
|
+
type ColumnLayoutFlags = {
|
|
3
|
+
readonly pinning: boolean;
|
|
4
|
+
readonly resizing: boolean;
|
|
5
|
+
};
|
|
6
|
+
/** Checks `pinned`, `width` and `minWidth` of every column against the table's opt-in flags. */
|
|
7
|
+
export declare function validateColumnLayout(where: string, flags: ColumnLayoutFlags, columns: readonly ListColumnSpec[]): void;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { normalizeListColumn } from "../screen-helpers.js";
|
|
3
|
+
import { LIST_COLUMN_PINS } from "../types/screen.js";
|
|
4
|
+
function isPositiveFiniteNumber(value) {
|
|
5
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
6
|
+
}
|
|
7
|
+
function validatePixelWidth(where, field, key, value) {
|
|
8
|
+
if (!isPositiveFiniteNumber(value)) {
|
|
9
|
+
throw new Error(`${where} column "${field}" has ${key} ${JSON.stringify(value)} — ` +
|
|
10
|
+
`must be a finite number of pixels greater than 0.`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Checks `pinned`, `width` and `minWidth` of every column against the table's opt-in flags. */
|
|
14
|
+
export function validateColumnLayout(where, flags, columns) {
|
|
15
|
+
for (const col of columns.map(normalizeListColumn)) {
|
|
16
|
+
if (col.pinned !== undefined) {
|
|
17
|
+
if (!isOneOf(LIST_COLUMN_PINS, col.pinned)) {
|
|
18
|
+
throw new Error(`${where} column "${col.field}" has pinned ${JSON.stringify(col.pinned)} — ` +
|
|
19
|
+
`must be one of ${LIST_COLUMN_PINS.map((pin) => `"${pin}"`).join(", ")}.`);
|
|
20
|
+
}
|
|
21
|
+
if (!flags.pinning) {
|
|
22
|
+
throw new Error(`${where} column "${col.field}" is pinned but table.pinning is not enabled, ` +
|
|
23
|
+
`so the pin would have no effect. Set table: { pinning: true } or remove "pinned".`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const key of ["width", "minWidth"]) {
|
|
27
|
+
const value = col[key];
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
validatePixelWidth(where, col.field, key, value);
|
|
31
|
+
if (!flags.resizing) {
|
|
32
|
+
throw new Error(`${where} column "${col.field}" sets ${key} but table.resizing is not enabled, ` +
|
|
33
|
+
`so it would have no effect. Set table: { resizing: true } or remove "${key}".`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (col.width !== undefined && col.minWidth !== undefined && col.minWidth > col.width) {
|
|
37
|
+
throw new Error(`${where} column "${col.field}" has minWidth ${col.minWidth} greater than width ${col.width}. ` +
|
|
38
|
+
`Lower minWidth or raise width.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EntityDefinition, FieldDefinition } from "../types/index.ts";
|
|
2
|
+
import { type ListColumnSpec, type ListTableGrouping, type ListTableTotal } from "../types/screen.ts";
|
|
3
|
+
type FieldType = FieldDefinition["type"];
|
|
4
|
+
export type AggregateFieldInfo = {
|
|
5
|
+
readonly type: FieldType;
|
|
6
|
+
readonly multiple: boolean;
|
|
7
|
+
readonly secret: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function groupingFieldsOf(grouping: {
|
|
10
|
+
readonly field?: unknown;
|
|
11
|
+
readonly fields?: unknown;
|
|
12
|
+
}): string[];
|
|
13
|
+
/** Structure and column membership; field types are checked where the entity is known. */
|
|
14
|
+
export declare function validateListTableAggregateShape(where: string, table: Readonly<Record<string, unknown>>, columns: readonly ListColumnSpec[]): void;
|
|
15
|
+
/**
|
|
16
|
+
* `lookup` answers undefined for a field it does not know. With `strict` (an entity
|
|
17
|
+
* stands behind the list) that is an error; otherwise the field is left to the server.
|
|
18
|
+
*/
|
|
19
|
+
export declare function validateListTableAggregateFieldTypes(where: string, table: {
|
|
20
|
+
readonly grouping?: ListTableGrouping;
|
|
21
|
+
readonly totals?: readonly ListTableTotal[];
|
|
22
|
+
}, lookup: (field: string) => AggregateFieldInfo | undefined, strict: boolean): void;
|
|
23
|
+
export declare function entityAggregateFieldLookup(entity: EntityDefinition): (field: string) => AggregateFieldInfo | undefined;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { LIST_AGGREGATE_MEASURE_KINDS } from "@cosmicdrift/kumiko-types/list-aggregate-types";
|
|
2
|
+
import { isOneOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
|
+
import { isListAggregateGroupable, isListAggregateMeasureSupported, } from "../../db/aggregate-sql.js";
|
|
4
|
+
import { holdsCiphertextOrSecret } from "../../db/entity-field-encryption.js";
|
|
5
|
+
import { normalizeListColumn } from "../screen-helpers.js";
|
|
6
|
+
import { LIST_TABLE_GROUPING_KEYS, LIST_TABLE_TOTAL_KEYS, } from "../types/screen.js";
|
|
7
|
+
import { rejectUnknownKeys } from "./list-table-reject-unknown-keys.js";
|
|
8
|
+
function isStringArray(value) {
|
|
9
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
10
|
+
}
|
|
11
|
+
function isStringRecord(value) {
|
|
12
|
+
return isPlainObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
13
|
+
}
|
|
14
|
+
function isGroupKeyScalar(value) {
|
|
15
|
+
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
16
|
+
}
|
|
17
|
+
function requireScreenColumn(where, path, field, columnFields) {
|
|
18
|
+
// skip: the field is a column, nothing to report
|
|
19
|
+
if (columnFields.has(field))
|
|
20
|
+
return;
|
|
21
|
+
throw new Error(`${where} ${path} "${field}" is not a column of this screen. ` +
|
|
22
|
+
`Columns: ${[...columnFields].join(", ")}.`);
|
|
23
|
+
}
|
|
24
|
+
const GROUPING_VALUE_CHECKS = [
|
|
25
|
+
{ key: "field", isValid: (v) => typeof v === "string", expected: "must be a string" },
|
|
26
|
+
{ key: "fields", isValid: isStringArray, expected: "must be an array of field names" },
|
|
27
|
+
{ key: "userChoice", isValid: (v) => typeof v === "boolean", expected: "must be a boolean" },
|
|
28
|
+
{ key: "label", isValid: (v) => typeof v === "string", expected: "must be an i18n key" },
|
|
29
|
+
{ key: "labels", isValid: isStringRecord, expected: "must map group keys to i18n keys" },
|
|
30
|
+
{
|
|
31
|
+
key: "collapsedWhen",
|
|
32
|
+
isValid: isGroupKeyScalar,
|
|
33
|
+
expected: "must be a string, number, boolean or null",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
function validateGroupingValueTypes(where, grouping) {
|
|
37
|
+
for (const check of GROUPING_VALUE_CHECKS) {
|
|
38
|
+
const value = grouping[check.key];
|
|
39
|
+
if (value !== undefined && !check.isValid(value)) {
|
|
40
|
+
throw new Error(`${where} table.grouping.${check.key} ${check.expected}.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function validateGroupingFieldChoice(where, grouping) {
|
|
45
|
+
const { field, fields, userChoice } = grouping;
|
|
46
|
+
const offered = isStringArray(fields) ? fields : [];
|
|
47
|
+
if (field === undefined && (userChoice !== true || offered.length === 0)) {
|
|
48
|
+
throw new Error(`${where} table.grouping has no field. Set field, or userChoice: true with at least one entry in fields.`);
|
|
49
|
+
}
|
|
50
|
+
if (fields !== undefined && userChoice !== true) {
|
|
51
|
+
throw new Error(`${where} table.grouping.fields needs userChoice: true, or the user could never pick them.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function validateGroupingShape(where, grouping, columnFields) {
|
|
55
|
+
if (!isPlainObject(grouping)) {
|
|
56
|
+
throw new Error(`${where} table.grouping must be an object like { field: "status" }.`);
|
|
57
|
+
}
|
|
58
|
+
rejectUnknownKeys(where, "table.grouping", grouping, LIST_TABLE_GROUPING_KEYS);
|
|
59
|
+
validateGroupingValueTypes(where, grouping);
|
|
60
|
+
validateGroupingFieldChoice(where, grouping);
|
|
61
|
+
for (const name of groupingFieldsOf(grouping)) {
|
|
62
|
+
requireScreenColumn(where, "table.grouping field", name, columnFields);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function validateTotalsShape(where, totals, columnFields) {
|
|
66
|
+
if (!Array.isArray(totals) || totals.length === 0) {
|
|
67
|
+
throw new Error(`${where} table.totals must be a non-empty array like [{ field: "amount", kind: "sum" }].`);
|
|
68
|
+
}
|
|
69
|
+
for (const [i, entry] of totals.entries()) {
|
|
70
|
+
if (!isPlainObject(entry))
|
|
71
|
+
throw new Error(`${where} table.totals[${i}] must be an object.`);
|
|
72
|
+
rejectUnknownKeys(where, `table.totals[${i}]`, entry, LIST_TABLE_TOTAL_KEYS);
|
|
73
|
+
if (typeof entry["field"] !== "string") {
|
|
74
|
+
throw new Error(`${where} table.totals[${i}].field must be a field name.`);
|
|
75
|
+
}
|
|
76
|
+
if (!isOneOf(LIST_AGGREGATE_MEASURE_KINDS, entry["kind"])) {
|
|
77
|
+
throw new Error(`${where} table.totals[${i}].kind must be one of ${LIST_AGGREGATE_MEASURE_KINDS.join(", ")}.`);
|
|
78
|
+
}
|
|
79
|
+
requireScreenColumn(where, `table.totals[${i}].field`, entry["field"], columnFields);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function groupingFieldsOf(grouping) {
|
|
83
|
+
const named = typeof grouping.field === "string" ? [grouping.field] : [];
|
|
84
|
+
const offered = isStringArray(grouping.fields) ? grouping.fields : [];
|
|
85
|
+
return [...new Set([...named, ...offered])];
|
|
86
|
+
}
|
|
87
|
+
/** Structure and column membership; field types are checked where the entity is known. */
|
|
88
|
+
export function validateListTableAggregateShape(where, table, columns) {
|
|
89
|
+
const columnFields = new Set(columns.map((col) => normalizeListColumn(col).field));
|
|
90
|
+
if (table["grouping"] !== undefined)
|
|
91
|
+
validateGroupingShape(where, table["grouping"], columnFields);
|
|
92
|
+
if (table["totals"] !== undefined)
|
|
93
|
+
validateTotalsShape(where, table["totals"], columnFields);
|
|
94
|
+
}
|
|
95
|
+
function rejectSecret(where, path, field, info) {
|
|
96
|
+
// skip: only secret fields are rejected here
|
|
97
|
+
if (!info.secret)
|
|
98
|
+
return;
|
|
99
|
+
throw new Error(`${where} ${path} "${field}" is encrypted, personal or secret and cannot be grouped or summed.`);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* `lookup` answers undefined for a field it does not know. With `strict` (an entity
|
|
103
|
+
* stands behind the list) that is an error; otherwise the field is left to the server.
|
|
104
|
+
*/
|
|
105
|
+
export function validateListTableAggregateFieldTypes(where, table, lookup, strict) {
|
|
106
|
+
const resolve = (path, field) => {
|
|
107
|
+
const info = lookup(field);
|
|
108
|
+
if (info === undefined && strict) {
|
|
109
|
+
throw new Error(`${where} ${path} "${field}" is not a field of the entity behind this list.`);
|
|
110
|
+
}
|
|
111
|
+
return info;
|
|
112
|
+
};
|
|
113
|
+
for (const field of groupingFieldsOf(table.grouping ?? {})) {
|
|
114
|
+
const info = resolve("table.grouping field", field);
|
|
115
|
+
if (info === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
rejectSecret(where, "table.grouping field", field, info);
|
|
118
|
+
if (!isListAggregateGroupable(info.type, info.multiple)) {
|
|
119
|
+
throw new Error(`${where} table.grouping field "${field}" (${info.type}) cannot be grouped by.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [i, total] of (table.totals ?? []).entries()) {
|
|
123
|
+
const path = `table.totals[${i}]`;
|
|
124
|
+
const info = resolve(`${path}.field`, total.field);
|
|
125
|
+
if (info === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
rejectSecret(where, `${path}.field`, total.field, info);
|
|
128
|
+
if (!isListAggregateMeasureSupported(total.kind, info.type)) {
|
|
129
|
+
throw new Error(`${where} ${path}: "${total.kind}" is not supported on ${info.type} field "${total.field}". ` +
|
|
130
|
+
`sum and avg need number, decimal, money or bigInt; min and max also take date and timestamp.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export function entityAggregateFieldLookup(entity) {
|
|
135
|
+
return (field) => {
|
|
136
|
+
const def = Object.hasOwn(entity.fields, field) ? entity.fields[field] : undefined;
|
|
137
|
+
if (def === undefined)
|
|
138
|
+
return undefined;
|
|
139
|
+
return {
|
|
140
|
+
type: def.type,
|
|
141
|
+
multiple: def.type === "reference" && def.multiple === true,
|
|
142
|
+
secret: holdsCiphertextOrSecret(entity, field),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FeatureDefinition, QueryHandlerDef } from "../types/index.ts";
|
|
2
|
+
import type { ListColumnSpec, ListTableOptions } from "../types/screen.ts";
|
|
3
|
+
export declare function validateListTableAggregateQuery(args: {
|
|
4
|
+
readonly prefix: string;
|
|
5
|
+
readonly query: string;
|
|
6
|
+
readonly table?: ListTableOptions | undefined;
|
|
7
|
+
readonly columns: readonly ListColumnSpec[];
|
|
8
|
+
readonly handler: QueryHandlerDef | undefined;
|
|
9
|
+
readonly featureMap: ReadonlyMap<string, FeatureDefinition>;
|
|
10
|
+
}): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolveEntityBehindListQuery } from "../entity-list-searchability.js";
|
|
2
|
+
import { normalizeListColumn } from "../screen-helpers.js";
|
|
3
|
+
import { entityAggregateFieldLookup, validateListTableAggregateFieldTypes, } from "./list-table-aggregate-options.js";
|
|
4
|
+
import { findEntity } from "./parent-ref.js";
|
|
5
|
+
import { getZodObjectShape } from "./zod-shape.js";
|
|
6
|
+
function columnValueTypeLookup(columns) {
|
|
7
|
+
const valueTypes = new Map(columns
|
|
8
|
+
.map(normalizeListColumn)
|
|
9
|
+
.flatMap((col) => (col.valueType === undefined ? [] : [[col.field, col.valueType]])));
|
|
10
|
+
return (field) => {
|
|
11
|
+
const type = valueTypes.get(field);
|
|
12
|
+
return type === undefined ? undefined : { type, multiple: false, secret: false };
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
// Same pattern as searchable vs search: grouping and totals send `aggregate`,
|
|
16
|
+
// which a query without it in its Zod schema would reject on every load.
|
|
17
|
+
export function validateListTableAggregateQuery(args) {
|
|
18
|
+
const { prefix, query, table, handler } = args;
|
|
19
|
+
// skip: neither grouping nor totals declared, nothing travels via aggregate
|
|
20
|
+
if (table?.grouping === undefined && table?.totals === undefined)
|
|
21
|
+
return;
|
|
22
|
+
const shape = getZodObjectShape(handler?.schema);
|
|
23
|
+
if (shape === undefined || !("aggregate" in shape)) {
|
|
24
|
+
throw new Error(`${prefix}: table.grouping/table.totals need query "${query}" to accept "aggregate" — ` +
|
|
25
|
+
`reuse entityListSchema (or listAggregatePayloadSchema) in the handler's schema and answer it ` +
|
|
26
|
+
`with answerListAggregate, or remove grouping and totals.`);
|
|
27
|
+
}
|
|
28
|
+
const entity = resolveEntityBehindListQuery(query, handler, (name) => findEntity(args.featureMap, name));
|
|
29
|
+
const lookup = entity === undefined ? columnValueTypeLookup(args.columns) : entityAggregateFieldLookup(entity);
|
|
30
|
+
validateListTableAggregateFieldTypes(prefix, table, lookup, entity !== undefined);
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function validateExportOptions(where: string, value: unknown, allowed: boolean): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { LIST_TABLE_EXPORT_KEYS, MAX_EXPORT_MAX_ROWS } from "../types/screen.js";
|
|
3
|
+
import { rejectUnknownKeys } from "./list-table-reject-unknown-keys.js";
|
|
4
|
+
export function validateExportOptions(where, value, allowed) {
|
|
5
|
+
if (!allowed) {
|
|
6
|
+
throw new Error(`${where} table.export is only available on entityList and projectionList screens. Remove it here.`);
|
|
7
|
+
}
|
|
8
|
+
if (!isPlainObject(value)) {
|
|
9
|
+
throw new Error(`${where} table.export must be an object like { xlsx: true, maxRows: 5000 }.`);
|
|
10
|
+
}
|
|
11
|
+
rejectUnknownKeys(where, "table.export", value, LIST_TABLE_EXPORT_KEYS);
|
|
12
|
+
if (value["xlsx"] !== true) {
|
|
13
|
+
throw new Error(`${where} table.export.xlsx must be true (the only export format).`);
|
|
14
|
+
}
|
|
15
|
+
if (value["maxRows"] !== undefined)
|
|
16
|
+
validateMaxRows(where, value["maxRows"]);
|
|
17
|
+
}
|
|
18
|
+
function validateMaxRows(where, maxRows) {
|
|
19
|
+
if (typeof maxRows !== "number" || !Number.isInteger(maxRows) || maxRows < 1) {
|
|
20
|
+
throw new Error(`${where} table.export.maxRows is ${JSON.stringify(maxRows)} — must be an integer >= 1. ` +
|
|
21
|
+
`Set a positive row limit or remove maxRows to use the default.`);
|
|
22
|
+
}
|
|
23
|
+
if (maxRows > MAX_EXPORT_MAX_ROWS) {
|
|
24
|
+
throw new Error(`${where} table.export.maxRows is ${maxRows} — must be at most ${MAX_EXPORT_MAX_ROWS} ` +
|
|
25
|
+
`(the workbook is built in memory). Lower maxRows.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type ListColumnSpec } from "../types/screen.ts";
|
|
2
|
+
/** `where` is the full message prefix, e.g. `[Feature x] Screen "y" (entityList)`. */
|
|
3
|
+
export declare function validateListTableOptions(where: string, table: unknown, columns: readonly ListColumnSpec[], exportAllowed?: boolean): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isOneOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { normalizeListColumn } from "../screen-helpers.js";
|
|
3
|
+
import { LIST_TABLE_DENSITIES, LIST_TABLE_DENSITY_KEYS, LIST_TABLE_OPTION_KEYS, } from "../types/screen.js";
|
|
4
|
+
import { validateColumnLayout } from "./list-column-layout.js";
|
|
5
|
+
import { validateListTableAggregateShape } from "./list-table-aggregate-options.js";
|
|
6
|
+
import { validateExportOptions } from "./list-table-export-options.js";
|
|
7
|
+
import { rejectUnknownKeys } from "./list-table-reject-unknown-keys.js";
|
|
8
|
+
const BOOLEAN_OPTION_KEYS = [
|
|
9
|
+
"rememberState",
|
|
10
|
+
"resetAll",
|
|
11
|
+
"columnPicker",
|
|
12
|
+
"pinning",
|
|
13
|
+
"resizing",
|
|
14
|
+
"keyboardNavigation",
|
|
15
|
+
];
|
|
16
|
+
function validateDensity(where, density) {
|
|
17
|
+
if (!isPlainObject(density)) {
|
|
18
|
+
throw new Error(`${where} table.density must be an object like { default: "compact", userChoice: true }.`);
|
|
19
|
+
}
|
|
20
|
+
rejectUnknownKeys(where, "table.density", density, LIST_TABLE_DENSITY_KEYS);
|
|
21
|
+
if (Object.keys(density).length === 0) {
|
|
22
|
+
throw new Error(`${where} table.density is empty. Set default (${LIST_TABLE_DENSITIES.join(" | ")}) ` +
|
|
23
|
+
`and/or userChoice, or remove table.density.`);
|
|
24
|
+
}
|
|
25
|
+
if (density["default"] !== undefined && !isOneOf(LIST_TABLE_DENSITIES, density["default"])) {
|
|
26
|
+
throw new Error(`${where} table.density.default is ${JSON.stringify(density["default"])} — ` +
|
|
27
|
+
`must be one of ${LIST_TABLE_DENSITIES.map((d) => `"${d}"`).join(", ")}.`);
|
|
28
|
+
}
|
|
29
|
+
if (density["userChoice"] !== undefined && typeof density["userChoice"] !== "boolean") {
|
|
30
|
+
throw new Error(`${where} table.density.userChoice must be a boolean.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function validateHiddenColumns(where, columnPickerOn, columns) {
|
|
34
|
+
const normalized = columns.map(normalizeListColumn);
|
|
35
|
+
for (const col of normalized) {
|
|
36
|
+
if (col.hidden !== undefined && typeof col.hidden !== "boolean") {
|
|
37
|
+
throw new Error(`${where} column "${col.field}" has a non-boolean "hidden".`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const hidden = normalized.filter((col) => col.hidden === true);
|
|
41
|
+
// skip: no hidden columns, nothing to validate
|
|
42
|
+
if (hidden.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
if (!columnPickerOn) {
|
|
45
|
+
throw new Error(`${where} column "${hidden[0]?.field}" is hidden but table.columnPicker is not enabled, ` +
|
|
46
|
+
`so the user could never show it. Set table: { columnPicker: true } or remove "hidden".`);
|
|
47
|
+
}
|
|
48
|
+
if (hidden.length === normalized.length) {
|
|
49
|
+
throw new Error(`${where} hides every column. At least one column must start visible — remove "hidden" from one.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** `where` is the full message prefix, e.g. `[Feature x] Screen "y" (entityList)`. */
|
|
53
|
+
export function validateListTableOptions(where, table, columns, exportAllowed = false) {
|
|
54
|
+
if (table === undefined) {
|
|
55
|
+
validateHiddenColumns(where, false, columns);
|
|
56
|
+
validateColumnLayout(where, { pinning: false, resizing: false }, columns);
|
|
57
|
+
// skip: no table block: nothing else to check
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!isPlainObject(table)) {
|
|
61
|
+
throw new Error(`${where} table must be an object like { rememberState: true }. ` +
|
|
62
|
+
`Allowed keys: ${LIST_TABLE_OPTION_KEYS.join(", ")}.`);
|
|
63
|
+
}
|
|
64
|
+
rejectUnknownKeys(where, "table", table, LIST_TABLE_OPTION_KEYS);
|
|
65
|
+
for (const key of BOOLEAN_OPTION_KEYS) {
|
|
66
|
+
if (table[key] !== undefined && typeof table[key] !== "boolean") {
|
|
67
|
+
throw new Error(`${where} table.${key} must be a boolean, got ${typeof table[key]}.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (table["density"] !== undefined)
|
|
71
|
+
validateDensity(where, table["density"]);
|
|
72
|
+
if (table["export"] !== undefined)
|
|
73
|
+
validateExportOptions(where, table["export"], exportAllowed);
|
|
74
|
+
validateListTableAggregateShape(where, table, columns);
|
|
75
|
+
validateHiddenColumns(where, table["columnPicker"] === true, columns);
|
|
76
|
+
validateColumnLayout(where, { pinning: table["pinning"] === true, resizing: table["resizing"] === true }, columns);
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function rejectUnknownKeys(where: string, block: string, value: Record<string, unknown>, allowed: readonly string[]): void;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function rejectUnknownKeys(where, block, value, allowed) {
|
|
2
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
3
|
+
if (unknown.length > 0) {
|
|
4
|
+
throw new Error(`${where} ${block} has unknown key(s) ${unknown.map((k) => `"${k}"`).join(", ")}. ` +
|
|
5
|
+
`Allowed keys: ${allowed.join(", ")}.`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { FeatureDefinition, QueryHandlerDef } from "../types/index.ts";
|
|
2
2
|
export declare function buildQueryHandlerMap(features: readonly FeatureDefinition[]): ReadonlyMap<string, QueryHandlerDef>;
|
|
3
3
|
export declare function validateProjectionListScreens(features: readonly FeatureDefinition[]): void;
|
|
4
4
|
export declare function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name.js";
|
|
3
3
|
import { isEditLayoutScreen } from "../screen-helpers.js";
|
|
4
|
-
import { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../types/index.js";
|
|
5
4
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
|
|
5
|
+
import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
|
|
6
6
|
import { findEntity } from "./parent-ref.js";
|
|
7
7
|
import { getZodObjectShape } from "./zod-shape.js";
|
|
8
8
|
// Sibling to entity-list-screens.ts rather than an extension of it:
|
|
@@ -66,6 +66,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
|
|
|
66
66
|
}
|
|
67
67
|
validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
|
|
68
68
|
validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
|
|
69
|
+
validateListTableAggregateQuery({ prefix, ...screen, handler, featureMap });
|
|
69
70
|
}
|
|
70
71
|
// filter sends `payload.filter` — same 422 footgun as facets below
|
|
71
72
|
// if the handler's schema doesn't accept it.
|
|
@@ -190,17 +191,6 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
|
|
|
190
191
|
`entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`);
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
|
-
// A collapsed group without a header has no toggle, so its rows could never be opened.
|
|
194
|
-
function validateRelatedListGroupBy(prefix, section) {
|
|
195
|
-
const groupBy = section.groupBy;
|
|
196
|
-
const collapsedKey = groupBy?.collapsedWhen === undefined ? undefined : relatedListGroupKey(groupBy.collapsedWhen);
|
|
197
|
-
if (groupBy !== undefined &&
|
|
198
|
-
collapsedKey !== undefined &&
|
|
199
|
-
relatedListGroupHeaderLabel(groupBy, collapsedKey) === undefined) {
|
|
200
|
-
throw new Error(`${prefix}: groupBy.collapsedWhen "${collapsedKey}" names a group without a header; ` +
|
|
201
|
-
`set groupBy.label or groupBy.labels["${collapsedKey}"], or drop collapsedWhen.`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
194
|
// relatedList sections declare search/facets the same way a projectionList
|
|
205
195
|
// screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
|
|
206
196
|
// auto-merge params into the handler's own Zod schema, so an
|
|
@@ -217,7 +207,7 @@ export function validateRelatedListSectionQueries(features) {
|
|
|
217
207
|
validateRelatedListSearchable(prefix, section, schema);
|
|
218
208
|
validateRelatedListFacets(prefix, section, schema);
|
|
219
209
|
validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
|
|
220
|
-
|
|
210
|
+
validateListTableAggregateQuery({ prefix, ...section, handler, featureMap });
|
|
221
211
|
};
|
|
222
212
|
for (const feature of features) {
|
|
223
213
|
for (const screen of Object.values(feature.screens)) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
const EXPANDABLE_ROW_TABLE_KEYS = new Set(["grouping"]);
|
|
3
|
+
const EXPANDABLE_ROW_REJECTED_GROUPING_KEYS = ["userChoice", "fields"];
|
|
4
|
+
// `groupBy` grouped the loaded page on the client; it is gone, and a screen
|
|
5
|
+
// still carrying it fails at boot instead of silently losing its groups.
|
|
6
|
+
function rejectRemovedGroupBy(prefix, section) {
|
|
7
|
+
if (Object.hasOwn(section, "groupBy")) {
|
|
8
|
+
throw new Error(`${prefix} uses groupBy, which was removed. Use table.grouping: { field, label?, labels?, collapsedWhen? }; ` +
|
|
9
|
+
`groups and counts come from the server, so the bound query must accept aggregate.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function rejectExpandableRowTableOptions(prefix, table) {
|
|
13
|
+
if (isPlainObject(table)) {
|
|
14
|
+
rejectUnsupportedTableKeys(prefix, table);
|
|
15
|
+
rejectGroupingChoice(prefix, table["grouping"]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function rejectUnsupportedTableKeys(prefix, table) {
|
|
19
|
+
const unsupported = Object.keys(table).filter((key) => !EXPANDABLE_ROW_TABLE_KEYS.has(key));
|
|
20
|
+
if (unsupported.length > 0) {
|
|
21
|
+
throw new Error(`${prefix} supports only table.grouping, not table.${unsupported.join(", table.")}. ` +
|
|
22
|
+
`Put the other table options on the entityList screen itself.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function rejectGroupingChoice(prefix, grouping) {
|
|
26
|
+
const choiceKey = isPlainObject(grouping)
|
|
27
|
+
? EXPANDABLE_ROW_REJECTED_GROUPING_KEYS.find((key) => Object.hasOwn(grouping, key))
|
|
28
|
+
: undefined;
|
|
29
|
+
if (choiceKey !== undefined) {
|
|
30
|
+
throw new Error(`${prefix} does not support table.grouping.${choiceKey}: an expanded row has no view menu. ` +
|
|
31
|
+
`Set a fixed table.grouping.field.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Grouping rules of a relatedList section or entityList.expandableRow, before the shared table checks. */
|
|
35
|
+
export function validateRelatedListGroupingRules(prefix, section, isExpandableRow) {
|
|
36
|
+
rejectRemovedGroupBy(prefix, section);
|
|
37
|
+
if (isExpandableRow && Object.hasOwn(section, "bulkActions")) {
|
|
38
|
+
throw new Error(`${prefix} does not support bulkActions. Put bulkActions on the entityList screen itself.`);
|
|
39
|
+
}
|
|
40
|
+
if (isExpandableRow)
|
|
41
|
+
rejectExpandableRowTableOptions(prefix, section.table);
|
|
42
|
+
}
|
|
@@ -3,7 +3,10 @@ import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
|
3
3
|
import { qualifyEntityName } from "../qualified-name.js";
|
|
4
4
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
|
|
5
5
|
import { normalizeListColumn } from "../screen-helpers.js";
|
|
6
|
+
import { validateEntityListAggregateOptions } from "./entity-list-aggregate-options.js";
|
|
6
7
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
8
|
+
import { validateListBulkActions } from "./list-bulk-actions.js";
|
|
9
|
+
import { validateListTableOptions } from "./list-table-options.js";
|
|
7
10
|
import { assertWriteHandlerRegistered, rejectRedirectOnListRowAction, resolveRowActionNavigateTarget, validateActionFieldRefs, validateAtMostOneRowClick, validateDrawerTargetAction, validateRowActionNavigateParams, } from "./screens-actions.js";
|
|
8
11
|
import { buildUnknownFieldMessage, listActionContext, rejectEmptyColumns, rejectNonArrayInFilter, resolveScreenEntity, validateListColumnRendering, } from "./screens-kind-shared.js";
|
|
9
12
|
import { validateRelatedListSection, validateRowActionTarget } from "./screens-lists.js";
|
|
@@ -201,6 +204,9 @@ export function validateEntityListScreen(ctx, screenId, screen) {
|
|
|
201
204
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (entityList) has pageSize=${screen.pageSize} — ` +
|
|
202
205
|
`must be a positive integer.`);
|
|
203
206
|
}
|
|
207
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (entityList)`;
|
|
208
|
+
validateListTableOptions(where, screen.table, screen.columns, true);
|
|
209
|
+
validateEntityListAggregateOptions(where, screen, fields.entityDef);
|
|
204
210
|
if (screen.defaultSort !== undefined) {
|
|
205
211
|
validateEntityListDefaultSort(featureName, screenId, screen.defaultSort, fields);
|
|
206
212
|
}
|
|
@@ -219,6 +225,7 @@ export function validateEntityListScreen(ctx, screenId, screen) {
|
|
|
219
225
|
}
|
|
220
226
|
validateAtMostOneRowClick(featureName, screenId, "entityList", screen.rowActions);
|
|
221
227
|
}
|
|
228
|
+
validateListBulkActions(`[Feature ${featureName}] Screen "${screenId}" (entityList)`, screen.bulkActions, screen.rowActions);
|
|
222
229
|
for (const action of screen.toolbarActions ?? []) {
|
|
223
230
|
validateEntityListToolbarAction(ctx, screenId, fields, action);
|
|
224
231
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { normalizeListColumn } from "../screen-helpers.js";
|
|
2
|
+
import { validateListBulkActions } from "./list-bulk-actions.js";
|
|
3
|
+
import { validateListTableOptions } from "./list-table-options.js";
|
|
2
4
|
import { assertWriteHandlerRegistered, resolveRowActionNavigateTarget, validateAtMostOneRowClick, validateDrawerTargetAction, } from "./screens-actions.js";
|
|
3
5
|
import { listActionContext, rejectEmptyColumns, rejectMissingQuery, rejectNonArrayInFilter, validateListColumnRendering, } from "./screens-kind-shared.js";
|
|
4
6
|
import { validateListActionTargets, validateListFacets } from "./screens-lists.js";
|
|
@@ -34,6 +36,7 @@ export function validateProjectionListScreen(ctx, screenId, screen) {
|
|
|
34
36
|
for (const col of screen.columns) {
|
|
35
37
|
validateListColumnRendering(ctx, screenId, "projectionList", normalizeListColumn(col));
|
|
36
38
|
}
|
|
39
|
+
validateListTableOptions(`[Feature ${featureName}] Screen "${screenId}" (projectionList)`, screen.table, screen.columns, true);
|
|
37
40
|
// Screen filter: field existence can't be checked without an entity (columns
|
|
38
41
|
// aren't a complete field inventory of the underlying query), so only the
|
|
39
42
|
// structure is pinned: "in" requires an array.
|
|
@@ -46,6 +49,7 @@ export function validateProjectionListScreen(ctx, screenId, screen) {
|
|
|
46
49
|
if (screen.rowActions !== undefined) {
|
|
47
50
|
validateProjectionListRowActions(ctx, screenId, screen.rowActions);
|
|
48
51
|
}
|
|
52
|
+
validateListBulkActions(`[Feature ${featureName}] Screen "${screenId}" (projectionList)`, screen.bulkActions, screen.rowActions);
|
|
49
53
|
if (screen.toolbarActions !== undefined) {
|
|
50
54
|
validateProjectionListToolbarActions(ctx, screenId, screen.toolbarActions);
|
|
51
55
|
}
|