@cosmicdrift/kumiko-framework 0.402.0 → 0.403.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/api/framework-engine.md +5 -1
- package/agents/api/framework-ui-types.md +4 -0
- package/agents/api/renderer-web.md +1 -1
- package/agents/api/types.md +33 -0
- package/agents/api.md +1 -1
- package/agents/boot-rules.md +12 -0
- package/agents/examples/dashboard-stats.md +58 -3
- package/dist/engine/boot-validator/boot-phases-app.js +5 -0
- package/dist/engine/boot-validator/boot-rules.d.ts +36 -0
- package/dist/engine/boot-validator/boot-rules.js +2 -0
- package/dist/engine/boot-validator/dashboard-stat-navigate.d.ts +2 -0
- package/dist/engine/boot-validator/dashboard-stat-navigate.js +77 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +5 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.js +5 -0
- package/dist/engine/boot-validator/entity-list-screens.js +12 -0
- package/dist/engine/boot-validator/named-filter-rules.d.ts +32 -0
- package/dist/engine/boot-validator/named-filter-rules.js +32 -0
- package/dist/engine/boot-validator/projection-list-screens.js +13 -0
- package/dist/engine/entity-handlers.d.ts +1 -0
- package/dist/engine/entity-handlers.js +13 -1
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/resolve-named-list-filters.d.ts +3 -0
- package/dist/engine/resolve-named-list-filters.js +30 -0
- package/package.json +3 -3
- package/src/changes.json +6 -0
|
@@ -35,7 +35,7 @@ function createDecimalField<R extends true | false = false>(config: { precision:
|
|
|
35
35
|
function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef; // Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`), never persisted.
|
|
36
36
|
function createEmbeddedField(schema: EmbeddedFieldDef["schema"], overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): EmbeddedFieldDef;
|
|
37
37
|
function createEmbeddedListField(schema: EmbeddedFieldDef["schema"], overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | "multiple" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): EmbeddedFieldDef & { multiple: true; };
|
|
38
|
-
function createEntity<const F, const T extends EntityTenancy = "tenant">(def: { table?: string; fields: F; description?: string; softDelete?: boolean; systemStream?: boolean; eventVersion?: number; eventMigrations?: EntityDefinition["eventMigrations"]; searchWeight?: number; defaultCurrency?: string; transitions?: Readonly<Record<string, Readonly<Record<string, string[]>>>>; lockedWhen?: EntityDefinition["lockedWhen"]; indexes?: EntityIndexDef[]; idType?: "serial" | "uuid"; access?: EntityDefinition["access"]; parentRef?: EntityDefinition["parentRef"]; readGate?: EntityDefinition["readGate"]; retention?: RetentionDef; derivedFields?: EntityDefinition["derivedFields"]; tenancy?: T; transferable?: boolean; attachedFileAccess?: EntityDefinition["attachedFileAccess"]; }): F extends FieldsMap ? EntityDefinition<F, T> : never;
|
|
38
|
+
function createEntity<const F, const T extends EntityTenancy = "tenant">(def: { table?: string; fields: F; description?: string; softDelete?: boolean; systemStream?: boolean; eventVersion?: number; eventMigrations?: EntityDefinition["eventMigrations"]; searchWeight?: number; defaultCurrency?: string; transitions?: Readonly<Record<string, Readonly<Record<string, string[]>>>>; lockedWhen?: EntityDefinition["lockedWhen"]; indexes?: EntityIndexDef[]; idType?: "serial" | "uuid"; access?: EntityDefinition["access"]; parentRef?: EntityDefinition["parentRef"]; readGate?: EntityDefinition["readGate"]; retention?: RetentionDef; derivedFields?: EntityDefinition["derivedFields"]; namedFilters?: EntityDefinition["namedFilters"]; tenancy?: T; transferable?: boolean; attachedFileAccess?: EntityDefinition["attachedFileAccess"]; }): F extends FieldsMap ? EntityDefinition<F, T> : never;
|
|
39
39
|
function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): { table: EntityTable; executor: EventStoreExecutor<EntityRow<E>>; };
|
|
40
40
|
function createFileField(overrides?: Partial<Omit<FileFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FileFieldDef;
|
|
41
41
|
function createFilesField(overrides?: Partial<Omit<FilesFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FilesFieldDef;
|
|
@@ -137,6 +137,7 @@ function referenceGroupLabels(ctx: HandlerContext, user: SessionUser, sourceTabl
|
|
|
137
137
|
function requireTierResolverPlugin(usage: RegistrarExtensionRegistration): TierResolverPlugin;
|
|
138
138
|
function resolveAgentExposure(def: { description?: string; agent?: AgentHandlerHints; }, kind: "query" | "write"): AgentExposure; // Fail-closed: a handler without a `description` stays invisible to the agent unless it opts in explicitly.
|
|
139
139
|
function resolveEntityTransferAdjacency(entities: ReadonlyMap<string, EntityDefinition>, rootEntityType?: string): TransferAdjacency;
|
|
140
|
+
function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<NamedListFilterCondition[]>;
|
|
140
141
|
function runPipeline<TPayload, TData, TMap extends object = KumikoEventTypeMap>(pipelineDef: PipelineDef<TPayload, TData>, event: WriteEvent<TPayload>, handlerCtx: HandlerContext<TMap>, workflow?: PipelineCtx["workflow"], resumeFrom?: number): Promise<WriteResult<TData>>;
|
|
141
142
|
function runStepList<TPayload, TMap extends object = KumikoEventTypeMap>(steps: StepInstance[], event: WriteEvent<TPayload>, handlerCtx: HandlerContext<TMap>, stepsAcc: Record<string, unknown>, scopeAcc: Record<string, unknown>, workflow?: PipelineCtx["workflow"], resumeFrom?: number): Promise<StepListOutcome>; // Walk a step-list.
|
|
142
143
|
function sectionFieldSpecs(section: FieldsOrGroupsSection): EditFieldSpec[];
|
|
@@ -564,6 +565,7 @@ type EntityDefinition<F extends FieldsMap = FieldsMap, T extends EntityTenancy =
|
|
|
564
565
|
readGate?: ReadGateDef; // Declarative read gate for rows readable only through other rows (a link to a visible host, a file whose owner is readable).
|
|
565
566
|
retention?: RetentionDef; // Default retention policy for this entity.
|
|
566
567
|
derivedFields?: DerivedFieldsMap; // Read-time computed fields, keyed by name.
|
|
568
|
+
namedFilters?: Readonly<Record<string, NamedListFilterDef>>; // Named work-queue rules keyed by id, selected via `namedFilter`; function-valued, never in the client schema.
|
|
567
569
|
description?: string; // Optional free-text semantics for the AI agent manifest — see TextFieldDef.description.
|
|
568
570
|
transferable?: boolean; // Opts the rows into `tenant-handover`, a grant-legitimated move from an anonymous tenant to the signed-up account's tenant.
|
|
569
571
|
attachedFileAccess?: "record-only"; // Attached files are readable only through the record's detail query: the files feature's privilegedRoles do not bypass it, the uploader check stays.
|
|
@@ -628,6 +630,7 @@ type EntityListScreenDefinition = {
|
|
|
628
630
|
defaultSort?: ListSortSpec;
|
|
629
631
|
defaultFilters?: Readonly<Record<string, string[] | boolean>>; // Initial facet selection, per filterable field: option values (string facets) or a boolean.
|
|
630
632
|
facets?: Readonly<Record<string, EntityListFacetConfig | false>>; // Per-facet display options for the entity's `filterable` fields, keyed by field name.
|
|
633
|
+
namedFilters?: NamedListFilterRef[]; // Named work-queue rules offered on this list: ids declared in `EntityDefinition.namedFilters`, `label` an i18n key.
|
|
631
634
|
emptyState?: ListEmptyStateSpec; // Replaces the default empty-list title/hint; the create button stays when the list can create.
|
|
632
635
|
createLabel?: string; // i18n key for the primary create button label (default `kumiko.actions.create`).
|
|
633
636
|
searchPlaceholder?: string; // i18n key for the search field placeholder (default `kumiko.list.search-placeholder`).
|
|
@@ -1351,6 +1354,7 @@ type ProjectionListScreenDefinition = {
|
|
|
1351
1354
|
paginated?: boolean; // Derived by buildAppSchema from the query handler's Zod schema (`cursor` or `offset` param present).
|
|
1352
1355
|
filter?: ScreenFilter; // Server-side filter, fixed on the screen — see `ScreenFilter` doc above (entityList).
|
|
1353
1356
|
facets?: ListFacetSpec[]; // User-toggleable facet dropdowns — see `ListFacetSpec` doc.
|
|
1357
|
+
namedFilters?: NamedListFilterRef[]; // Named work-queue rules the bound query understands: its Zod schema must have a `namedFilter` key (boot-checked) and is sent the selected id in every list and count query.
|
|
1354
1358
|
slots?: ScreenSlots;
|
|
1355
1359
|
access?: AccessRule;
|
|
1356
1360
|
};
|
|
@@ -252,6 +252,10 @@ type DashboardStatPanel = DashboardPanelQueryOptions & DashboardPanelGate & {
|
|
|
252
252
|
deltaToneField?: string;
|
|
253
253
|
icon?: PlatformComponent; // Static icon next to the label.
|
|
254
254
|
accentColor?: string; // Static CSS color value (e.g. "var(--color-debt)") for the icon chip, passed straight to the tile without a registry lookup.
|
|
255
|
+
navigate?: {
|
|
256
|
+
screen: string;
|
|
257
|
+
namedFilter?: string;
|
|
258
|
+
}; // Makes the tile a link to a list screen (qualified screen id).
|
|
255
259
|
};
|
|
256
260
|
type DashboardText = string | DashboardI18nText; // Plain string is displayed as-is; DashboardI18nText is translated by the renderer.
|
|
257
261
|
type DashboardTextBlock =
|
|
@@ -85,7 +85,7 @@ function SidebarProvider(props: React.ComponentProps<"div"> & { defaultOpen?: bo
|
|
|
85
85
|
function SidebarUser(props: SidebarUserProps): ReactNode;
|
|
86
86
|
function Sparkline(props: { points: number[]; className?: string; }): ReactNode;
|
|
87
87
|
function Stack(props: StackProps): ReactNode;
|
|
88
|
-
function StatCard(props: { icon?: ReactNode; label: string; value: string; sub?: string; tone?: StatTone; accentColor?: string; delta?: StatDelta; trend?: string; spark?: number[]; testId?: string; children?: ReactNode; }): ReactNode; // Icon-bearing metric tile with an optional delta chip, trend line, and sparkline.
|
|
88
|
+
function StatCard(props: { icon?: ReactNode; label: string; value: string; sub?: string; tone?: StatTone; accentColor?: string; delta?: StatDelta; trend?: string; spark?: number[]; testId?: string; link?: StatLink; children?: ReactNode; }): ReactNode; // Icon-bearing metric tile with an optional delta chip, trend line, and sparkline.
|
|
89
89
|
function StatusBadge(props: { tone: StatusBadgeTone; children: ReactNode; className?: string; testId?: string; }): ReactNode; // Pill badge with a status dot.
|
|
90
90
|
function StatusBarChart(props: { entries: StatusBarEntry[]; ariaLabel: string; startLabel?: string; endLabel?: string; highlightLast?: boolean; dense?: boolean; testId?: string; }): ReactNode; // Status bar strip (e.g. 90-day uptime): variable-height bars with gradient fade + tick line on top; the last entry gets a "now" accent stripe.
|
|
91
91
|
function StepBar(props: { steps: string[]; currentIndex: number; compactLabel: string; onStepSelect?: (index: number) => void; doneSteps?: boolean[]; selectableSteps?: "done" | "all"; narrowLayout?: "label" | "steps"; orientation?: "horizontal" | "vertical"; heading?: string; description?: string; subtitles?: (string | undefined)[]; upNext?: { heading: string; title: string; subtitle?: string; }; testId?: string; compactTestId?: string; }): ReactNode; // Wizard step overview — numbered chips with a connector line between them.
|
package/agents/api/types.md
CHANGED
|
@@ -2676,6 +2676,15 @@ interface ResultTableFooterRow {
|
|
|
2676
2676
|
}
|
|
2677
2677
|
```
|
|
2678
2678
|
|
|
2679
|
+
## packages/renderer/src/web/widgets/stat.tsx
|
|
2680
|
+
|
|
2681
|
+
```ts
|
|
2682
|
+
type StatLink = { // Makes a tile a link; `onPress` is the SPA navigation, `href` serves modified clicks and a11y.
|
|
2683
|
+
href: string;
|
|
2684
|
+
onPress: () => void;
|
|
2685
|
+
};
|
|
2686
|
+
```
|
|
2687
|
+
|
|
2679
2688
|
## packages/testing/src/bundled-injection.ts
|
|
2680
2689
|
|
|
2681
2690
|
```ts
|
|
@@ -4377,6 +4386,30 @@ type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
|
|
|
4377
4386
|
};
|
|
4378
4387
|
```
|
|
4379
4388
|
|
|
4389
|
+
## packages/types/src/named-list-filters.ts
|
|
4390
|
+
|
|
4391
|
+
```ts
|
|
4392
|
+
type NamedListFilterCondition = {
|
|
4393
|
+
field: string;
|
|
4394
|
+
op: NamedListFilterOp;
|
|
4395
|
+
value: unknown;
|
|
4396
|
+
};
|
|
4397
|
+
type NamedListFilterDef = { // A work-queue rule defined once on the entity: the list and every count query resolve the same conditions.
|
|
4398
|
+
access?: AccessRule;
|
|
4399
|
+
filters: (scope: NamedListFilterScope) => NamedListFilterCondition[] | Promise<NamedListFilterCondition[]>;
|
|
4400
|
+
};
|
|
4401
|
+
type NamedListFilterOp = "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
|
|
4402
|
+
type NamedListFilterRef = { // Screen-side reference to an entity-defined named filter; `label` is an i18n key.
|
|
4403
|
+
id: string;
|
|
4404
|
+
label: string;
|
|
4405
|
+
};
|
|
4406
|
+
type NamedListFilterScope = {
|
|
4407
|
+
ctx: HandlerContext;
|
|
4408
|
+
user: SessionUser;
|
|
4409
|
+
now: Temporal.Instant;
|
|
4410
|
+
};
|
|
4411
|
+
```
|
|
4412
|
+
|
|
4380
4413
|
## packages/types/src/nav.ts
|
|
4381
4414
|
|
|
4382
4415
|
```ts
|
package/agents/api.md
CHANGED
|
@@ -8,7 +8,7 @@ Format: one line per function or constant, object and options types as property
|
|
|
8
8
|
|
|
9
9
|
| Import path | File | Exports |
|
|
10
10
|
| --- | --- | --- |
|
|
11
|
-
| `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` |
|
|
11
|
+
| `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 420 |
|
|
12
12
|
| `@cosmicdrift/kumiko-framework/errors` | `api/framework-errors.md` | 25 |
|
|
13
13
|
| `@cosmicdrift/kumiko-framework/ui-types` | `api/framework-ui-types.md` | 154 |
|
|
14
14
|
| `@cosmicdrift/kumiko-framework/db` | `api/framework-db.md` | 150 |
|
package/agents/boot-rules.md
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
| <a id="entity-list-detail-without-navigate"></a>`entity-list/detail-without-navigate` | A list whose entity has a detail screen offers a navigate row action. | Add a navigate rowAction with id "view" or "edit". | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
29
29
|
| <a id="entity-list-searchable-false"></a>`entity-list/searchable-false` | Operator lists are searchable unless they are on the searchable-false whitelist. | Set searchable: true or add the screen id to the whitelist. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
30
30
|
| <a id="entity-list-unknown-column"></a>`entity-list/unknown-column` | Every column without a label names an entity field, derived field or row-meta column. | Fix the column field name or give the column a label. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
31
|
+
| <a id="entity-list-unknown-named-filter"></a>`entity-list/unknown-named-filter` | Every screen namedFilters id is declared in the entity's namedFilters. | Declare the rule in EntityDefinition.namedFilters or remove the id from the screen. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
31
32
|
|
|
32
33
|
## extension
|
|
33
34
|
|
|
@@ -52,6 +53,17 @@
|
|
|
52
53
|
| --- | --- | --- | --- |
|
|
53
54
|
| <a id="list-card-meta-overflow"></a>`list/card-meta-overflow` | A card list shows at most the maximum number of meta columns. | Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true. | `packages/framework/src/engine/boot-validator/card-meta-overflow-rules.ts` |
|
|
54
55
|
|
|
56
|
+
## named-filter
|
|
57
|
+
|
|
58
|
+
| Rule | Summary | Fix | Source |
|
|
59
|
+
| --- | --- | --- | --- |
|
|
60
|
+
| <a id="named-filter-projection-query-accepts-named-filter"></a>`named-filter/projection-query-accepts-named-filter` | A projectionList with namedFilters binds a query whose schema has a namedFilter key. | Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
|
|
61
|
+
| <a id="named-filter-stat-navigate-filter-declared"></a>`named-filter/stat-navigate-filter-declared` | A stat navigate.namedFilter is declared in the target screen's namedFilters. | Add { id, label } to namedFilters of the target screen. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
|
|
62
|
+
| <a id="named-filter-stat-navigate-same-named-filter"></a>`named-filter/stat-navigate-same-named-filter` | A stat's params.namedFilter equals navigate.namedFilter. | Set params.namedFilter to the same id as navigate.namedFilter. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
|
|
63
|
+
| <a id="named-filter-stat-navigate-same-query"></a>`named-filter/stat-navigate-same-query` | A stat with navigate counts with the target list's own query. | Set the stat query to the target list's query. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
|
|
64
|
+
| <a id="named-filter-stat-navigate-static-filter"></a>`named-filter/stat-navigate-static-filter` | A stat repeats the target screen's static filter in params.filter. | Set params.filter to the target screen's filter. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
|
|
65
|
+
| <a id="named-filter-stat-navigate-target"></a>`named-filter/stat-navigate-target` | A stat navigate.screen resolves to an entityList or projectionList screen. | Point navigate.screen at a registered list screen. | `packages/framework/src/engine/boot-validator/named-filter-rules.ts` |
|
|
66
|
+
|
|
55
67
|
## pii
|
|
56
68
|
|
|
57
69
|
| Rule | Summary | Fix | Source |
|
|
@@ -36,6 +36,11 @@ query and the fields to read, the query does the counting.
|
|
|
36
36
|
- **Fallback by reason**: `fallback: { forbidden, notFound }` renders a screen on
|
|
37
37
|
403/404 of the gate query; other reasons get the default notice or an error
|
|
38
38
|
banner with retry.
|
|
39
|
+
- **Named filter shared by tile and list**: the entity declares `namedFilters`,
|
|
40
|
+
the `ticket-queue` entityList lists the id in `namedFilters`, and the
|
|
41
|
+
`open-work` stat counts through the list's own query with `params.namedFilter`
|
|
42
|
+
and `navigate`. A click opens the queue filtered by the same rule, so tile and
|
|
43
|
+
list cannot drift.
|
|
39
44
|
|
|
40
45
|
## When to reach for it
|
|
41
46
|
|
|
@@ -73,6 +78,7 @@ import {
|
|
|
73
78
|
} from "@cosmicdrift/kumiko-testing/stack";
|
|
74
79
|
import {
|
|
75
80
|
TICKET_CAPABILITIES_QUERY,
|
|
81
|
+
TICKET_LIST_QUERY,
|
|
76
82
|
TICKET_STATS_QUERY,
|
|
77
83
|
ticketEntity,
|
|
78
84
|
ticketFeature,
|
|
@@ -100,11 +106,11 @@ describe("dashboard screen", () => {
|
|
|
100
106
|
const stats = screen.panels.flatMap((panel) =>
|
|
101
107
|
panel.kind === "stat-group" ? panel.stats : panel.kind === "stat" ? [panel] : [],
|
|
102
108
|
);
|
|
103
|
-
expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total"]);
|
|
104
|
-
|
|
109
|
+
expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total", "total"]);
|
|
110
|
+
const statsQueryStats = stats.filter((s) => s.query === TICKET_STATS_QUERY);
|
|
105
111
|
|
|
106
112
|
const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
|
|
107
|
-
for (const stat of
|
|
113
|
+
for (const stat of statsQueryStats) expect(result).toHaveProperty(stat.valueField);
|
|
108
114
|
});
|
|
109
115
|
});
|
|
110
116
|
|
|
@@ -165,6 +171,28 @@ describe("record-bound dashboard with a gated detail panel", () => {
|
|
|
165
171
|
});
|
|
166
172
|
});
|
|
167
173
|
});
|
|
174
|
+
|
|
175
|
+
describe("named filter shared by the dashboard tile and the list", () => {
|
|
176
|
+
test("the tile total equals the filtered list total over HTTP", async () => {
|
|
177
|
+
const dashboard = ticketFeature.screens["ticket-dashboard"];
|
|
178
|
+
if (dashboard?.type !== "dashboard") throw new Error("ticket-dashboard missing");
|
|
179
|
+
const tile = dashboard.panels.find(
|
|
180
|
+
(panel) => panel.kind === "stat" && panel.id === "open-work",
|
|
181
|
+
);
|
|
182
|
+
if (tile?.kind !== "stat") throw new Error("open-work tile missing");
|
|
183
|
+
expect(tile.navigate).toEqual({ screen: "ticket-queue", namedFilter: "open-work" });
|
|
184
|
+
|
|
185
|
+
const counted = await stack.http.queryOk<{ total: number }>(tile.query, tile.params, admin);
|
|
186
|
+
const listed = await stack.http.queryOk<{ rows: unknown[]; total: number }>(
|
|
187
|
+
TICKET_LIST_QUERY,
|
|
188
|
+
{ namedFilter: "open-work", totalCount: true, limit: 50 },
|
|
189
|
+
admin,
|
|
190
|
+
);
|
|
191
|
+
expect(counted.total).toBe(listed.total);
|
|
192
|
+
expect(listed.rows).toHaveLength(listed.total);
|
|
193
|
+
expect(listed.total).toBeGreaterThan(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
168
196
|
```
|
|
169
197
|
|
|
170
198
|
### src/feature.ts
|
|
@@ -193,10 +221,15 @@ export const ticketEntity = createEntity({
|
|
|
193
221
|
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
194
222
|
status: createSelectField({ options: TICKET_STATUSES, default: "open", required: true }),
|
|
195
223
|
},
|
|
224
|
+
// Defined once: the queue list and the dashboard tile both read it.
|
|
225
|
+
namedFilters: {
|
|
226
|
+
"open-work": { filters: () => [{ field: "status", op: "eq", value: "open" }] },
|
|
227
|
+
},
|
|
196
228
|
});
|
|
197
229
|
|
|
198
230
|
export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
|
|
199
231
|
export const TICKET_CAPABILITIES_QUERY = "tickets:query:ticket:capabilities";
|
|
232
|
+
export const TICKET_LIST_QUERY = "tickets:query:ticket:list";
|
|
200
233
|
|
|
201
234
|
const readerRoles = { roles: ["Admin", "User"] } as const;
|
|
202
235
|
|
|
@@ -281,6 +314,9 @@ export const ticketFeature = defineFeature("tickets", (r) => {
|
|
|
281
314
|
"tickets.dashboard.open": { en: "Open" },
|
|
282
315
|
"tickets.dashboard.closed": { en: "Closed" },
|
|
283
316
|
"tickets.dashboard.total": { en: "All tickets" },
|
|
317
|
+
"tickets.dashboard.open-work": { en: "Open work" },
|
|
318
|
+
"tickets.filter.open-work": { en: "Open work" },
|
|
319
|
+
"screen:ticket-queue.title": { en: "Ticket queue" },
|
|
284
320
|
},
|
|
285
321
|
});
|
|
286
322
|
|
|
@@ -316,6 +352,16 @@ export const ticketFeature = defineFeature("tickets", (r) => {
|
|
|
316
352
|
query: TICKET_STATS_QUERY,
|
|
317
353
|
valueField: "total",
|
|
318
354
|
},
|
|
355
|
+
// Counts through the list's own query with the same named filter, then links to that list.
|
|
356
|
+
{
|
|
357
|
+
kind: "stat",
|
|
358
|
+
id: "open-work",
|
|
359
|
+
label: "tickets.dashboard.open-work",
|
|
360
|
+
query: TICKET_LIST_QUERY,
|
|
361
|
+
valueField: "total",
|
|
362
|
+
params: { namedFilter: "open-work", limit: 1, totalCount: true },
|
|
363
|
+
navigate: { screen: "ticket-queue", namedFilter: "open-work" },
|
|
364
|
+
},
|
|
319
365
|
],
|
|
320
366
|
access: readerRoles,
|
|
321
367
|
});
|
|
@@ -324,6 +370,15 @@ export const ticketFeature = defineFeature("tickets", (r) => {
|
|
|
324
370
|
// button, and a fallback chosen by reason.
|
|
325
371
|
const gate = { query: TICKET_CAPABILITIES_QUERY, field: "canClose", eq: true } as const;
|
|
326
372
|
|
|
373
|
+
r.screen({
|
|
374
|
+
id: "ticket-queue",
|
|
375
|
+
type: "entityList",
|
|
376
|
+
entity: "ticket",
|
|
377
|
+
columns: ["title", "status"],
|
|
378
|
+
namedFilters: [{ id: "open-work", label: "tickets.filter.open-work" }],
|
|
379
|
+
access: readerRoles,
|
|
380
|
+
});
|
|
381
|
+
|
|
327
382
|
// The list doubles as the fallback target: it has no visibleWhen of its own.
|
|
328
383
|
r.screen({
|
|
329
384
|
id: "ticket-list",
|
|
@@ -5,6 +5,7 @@ import { validateProjectionAllowlist } from "../validate-projection-allowlist.js
|
|
|
5
5
|
import { runFeatureBootCheck } from "./boot-check.js";
|
|
6
6
|
import { validateCardMetaOverflow } from "./card-meta-overflow.js";
|
|
7
7
|
import { validateConfigReads } from "./config-deps.js";
|
|
8
|
+
import { validateDashboardStatNavigate } from "./dashboard-stat-navigate.js";
|
|
8
9
|
import { validateExtensionPreSaveWiring } from "./entity-handler.js";
|
|
9
10
|
import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
10
11
|
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
@@ -45,6 +46,10 @@ const APP_CHECKS_AFTER_I18N = [
|
|
|
45
46
|
check: "validateProjectionListScreens",
|
|
46
47
|
run: (c) => validateProjectionListScreens(c.features),
|
|
47
48
|
},
|
|
49
|
+
{
|
|
50
|
+
check: "validateDashboardStatNavigate",
|
|
51
|
+
run: (c) => validateDashboardStatNavigate(c.features),
|
|
52
|
+
},
|
|
48
53
|
{
|
|
49
54
|
check: "validateRelatedListSectionQueries",
|
|
50
55
|
run: (c) => validateRelatedListSectionQueries(c.features),
|
|
@@ -131,11 +131,47 @@ export declare const FRAMEWORK_BOOT_RULE_TABLES: readonly [{
|
|
|
131
131
|
readonly summary: "A list whose entity has a detail screen offers a navigate row action.";
|
|
132
132
|
readonly fix: 'Add a navigate rowAction with id "view" or "edit".';
|
|
133
133
|
};
|
|
134
|
+
readonly unknownNamedFilter: {
|
|
135
|
+
readonly id: "entity-list/unknown-named-filter";
|
|
136
|
+
readonly summary: "Every screen namedFilters id is declared in the entity's namedFilters.";
|
|
137
|
+
readonly fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.";
|
|
138
|
+
};
|
|
134
139
|
readonly unknownColumn: {
|
|
135
140
|
readonly id: "entity-list/unknown-column";
|
|
136
141
|
readonly summary: "Every column without a label names an entity field, derived field or row-meta column.";
|
|
137
142
|
readonly fix: "Fix the column field name or give the column a label.";
|
|
138
143
|
};
|
|
144
|
+
}, {
|
|
145
|
+
readonly projectionQueryAcceptsNamedFilter: {
|
|
146
|
+
readonly id: "named-filter/projection-query-accepts-named-filter";
|
|
147
|
+
readonly summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.";
|
|
148
|
+
readonly fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.";
|
|
149
|
+
};
|
|
150
|
+
readonly statNavigateTarget: {
|
|
151
|
+
readonly id: "named-filter/stat-navigate-target";
|
|
152
|
+
readonly summary: "A stat navigate.screen resolves to an entityList or projectionList screen.";
|
|
153
|
+
readonly fix: "Point navigate.screen at a registered list screen.";
|
|
154
|
+
};
|
|
155
|
+
readonly statNavigateFilterDeclared: {
|
|
156
|
+
readonly id: "named-filter/stat-navigate-filter-declared";
|
|
157
|
+
readonly summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.";
|
|
158
|
+
readonly fix: "Add { id, label } to namedFilters of the target screen.";
|
|
159
|
+
};
|
|
160
|
+
readonly statNavigateSameQuery: {
|
|
161
|
+
readonly id: "named-filter/stat-navigate-same-query";
|
|
162
|
+
readonly summary: "A stat with navigate counts with the target list's own query.";
|
|
163
|
+
readonly fix: "Set the stat query to the target list's query.";
|
|
164
|
+
};
|
|
165
|
+
readonly statNavigateSameNamedFilter: {
|
|
166
|
+
readonly id: "named-filter/stat-navigate-same-named-filter";
|
|
167
|
+
readonly summary: "A stat's params.namedFilter equals navigate.namedFilter.";
|
|
168
|
+
readonly fix: "Set params.namedFilter to the same id as navigate.namedFilter.";
|
|
169
|
+
};
|
|
170
|
+
readonly statNavigateStaticFilter: {
|
|
171
|
+
readonly id: "named-filter/stat-navigate-static-filter";
|
|
172
|
+
readonly summary: "A stat repeats the target screen's static filter in params.filter.";
|
|
173
|
+
readonly fix: "Set params.filter to the target screen's filter.";
|
|
174
|
+
};
|
|
139
175
|
}, {
|
|
140
176
|
readonly missingFeatureKey: {
|
|
141
177
|
readonly id: "i18n/missing-feature-key";
|
|
@@ -3,11 +3,13 @@ import { BOOT_CHECK_RULES } from "./boot-check-rules.js";
|
|
|
3
3
|
import { CARD_META_OVERFLOW_RULES } from "./card-meta-overflow-rules.js";
|
|
4
4
|
import { ENTITY_LIST_RULES } from "./entity-list-screens-rules.js";
|
|
5
5
|
import { I18N_RULES } from "./i18n-keys-rules.js";
|
|
6
|
+
import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
|
|
6
7
|
import { PII_RULES } from "./pii-retention-rules.js";
|
|
7
8
|
import { SCREEN_ACTION_RULES } from "./screens-actions-rules.js";
|
|
8
9
|
export const FRAMEWORK_BOOT_RULE_TABLES = [
|
|
9
10
|
PII_RULES,
|
|
10
11
|
ENTITY_LIST_RULES,
|
|
12
|
+
NAMED_FILTER_RULES,
|
|
11
13
|
I18N_RULES,
|
|
12
14
|
CARD_META_OVERFLOW_RULES,
|
|
13
15
|
SCREEN_ACTION_RULES,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { toKebab } from "../qualified-name.js";
|
|
3
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
4
|
+
import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
|
|
5
|
+
import { resolveScreenTargetQn } from "./screens-actions.js";
|
|
6
|
+
function entityListQueryName(feature, screen) {
|
|
7
|
+
return `${toKebab(feature.name)}:query:${toKebab(screen.entity)}:list`;
|
|
8
|
+
}
|
|
9
|
+
function listQueryOf(target) {
|
|
10
|
+
return target.screen.type === "entityList"
|
|
11
|
+
? entityListQueryName(target.feature, target.screen)
|
|
12
|
+
: target.screen.query;
|
|
13
|
+
}
|
|
14
|
+
function declaredNamedFilterIds(target) {
|
|
15
|
+
return new Set((target.screen.namedFilters ?? []).map((ref) => ref.id));
|
|
16
|
+
}
|
|
17
|
+
function resolveListTarget(featureName, screenRef, featureMap) {
|
|
18
|
+
const qn = resolveScreenTargetQn(featureName, screenRef);
|
|
19
|
+
const [targetFeatureName = "", , targetShortId = ""] = qn.split(":");
|
|
20
|
+
const feature = featureMap.get(targetFeatureName);
|
|
21
|
+
const screen = feature?.screens[targetShortId];
|
|
22
|
+
if (feature === undefined || screen === undefined)
|
|
23
|
+
return { qn };
|
|
24
|
+
if (screen.type !== "entityList" && screen.type !== "projectionList")
|
|
25
|
+
return { qn };
|
|
26
|
+
return { feature, screen };
|
|
27
|
+
}
|
|
28
|
+
function validateStatNavigate(context, featureName, stat, featureMap) {
|
|
29
|
+
const navigate = stat.navigate;
|
|
30
|
+
// skip: stat has no navigate target to validate
|
|
31
|
+
if (navigate === undefined)
|
|
32
|
+
return;
|
|
33
|
+
const target = resolveListTarget(featureName, navigate.screen, featureMap);
|
|
34
|
+
if ("qn" in target) {
|
|
35
|
+
throw bootRuleError(NAMED_FILTER_RULES.statNavigateTarget, {
|
|
36
|
+
message: `${context} navigate.screen "${navigate.screen}" does not resolve to a registered entityList or projectionList screen (checked "${target.qn}").`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (navigate.namedFilter !== undefined &&
|
|
40
|
+
!declaredNamedFilterIds(target).has(navigate.namedFilter)) {
|
|
41
|
+
throw bootRuleError(NAMED_FILTER_RULES.statNavigateFilterDeclared, {
|
|
42
|
+
message: `${context} navigate.namedFilter "${navigate.namedFilter}" is not declared in namedFilters of screen "${target.screen.id}". Add { id, label } to that screen.`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const listQuery = listQueryOf(target);
|
|
46
|
+
if (stat.query !== listQuery) {
|
|
47
|
+
throw bootRuleError(NAMED_FILTER_RULES.statNavigateSameQuery, {
|
|
48
|
+
message: `${context} navigate targets screen "${target.screen.id}" whose list query is "${listQuery}", but the stat counts with query "${stat.query}". Count with the list's own query so tile and list cannot drift.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (stat.params?.["namedFilter"] !== navigate.namedFilter) {
|
|
52
|
+
throw bootRuleError(NAMED_FILTER_RULES.statNavigateSameNamedFilter, {
|
|
53
|
+
message: `${context} params.namedFilter (${JSON.stringify(stat.params?.["namedFilter"])}) must equal navigate.namedFilter (${JSON.stringify(navigate.namedFilter)}), otherwise the tile counts other rows than the list shows.`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const staticFilter = target.screen.filter;
|
|
57
|
+
if (staticFilter !== undefined && !isDeepStrictEqual(stat.params?.["filter"], staticFilter)) {
|
|
58
|
+
throw bootRuleError(NAMED_FILTER_RULES.statNavigateStaticFilter, {
|
|
59
|
+
message: `${context} target screen "${target.screen.id}" has a static filter the tile does not apply. Set params.filter to the same filter on the stat.`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function validateDashboardStatNavigate(features) {
|
|
64
|
+
const featureMap = new Map(features.map((f) => [f.name, f]));
|
|
65
|
+
for (const feature of features) {
|
|
66
|
+
for (const screen of Object.values(feature.screens)) {
|
|
67
|
+
if (screen.type !== "dashboard")
|
|
68
|
+
continue;
|
|
69
|
+
for (const panel of screen.panels) {
|
|
70
|
+
const stats = panel.kind === "stat" ? [panel] : panel.kind === "stat-group" ? panel.stats : [];
|
|
71
|
+
for (const stat of stats) {
|
|
72
|
+
validateStatNavigate(`[Feature ${feature.name}] Screen "${screen.id}" (dashboard) stat "${stat.id}"`, feature.name, stat, featureMap);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -24,6 +24,11 @@ export declare const ENTITY_LIST_RULES: {
|
|
|
24
24
|
readonly summary: "A list whose entity has a detail screen offers a navigate row action.";
|
|
25
25
|
readonly fix: 'Add a navigate rowAction with id "view" or "edit".';
|
|
26
26
|
};
|
|
27
|
+
readonly unknownNamedFilter: {
|
|
28
|
+
readonly id: "entity-list/unknown-named-filter";
|
|
29
|
+
readonly summary: "Every screen namedFilters id is declared in the entity's namedFilters.";
|
|
30
|
+
readonly fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.";
|
|
31
|
+
};
|
|
27
32
|
readonly unknownColumn: {
|
|
28
33
|
readonly id: "entity-list/unknown-column";
|
|
29
34
|
readonly summary: "Every column without a label names an entity field, derived field or row-meta column.";
|
|
@@ -24,6 +24,11 @@ export const ENTITY_LIST_RULES = {
|
|
|
24
24
|
summary: "A list whose entity has a detail screen offers a navigate row action.",
|
|
25
25
|
fix: 'Add a navigate rowAction with id "view" or "edit".',
|
|
26
26
|
},
|
|
27
|
+
unknownNamedFilter: {
|
|
28
|
+
id: "entity-list/unknown-named-filter",
|
|
29
|
+
summary: "Every screen namedFilters id is declared in the entity's namedFilters.",
|
|
30
|
+
fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.",
|
|
31
|
+
},
|
|
27
32
|
unknownColumn: {
|
|
28
33
|
id: "entity-list/unknown-column",
|
|
29
34
|
summary: "Every column without a label names an entity field, derived field or row-meta column.",
|
|
@@ -95,12 +95,24 @@ function checkColumns(feature, screen) {
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
|
+
function checkNamedFilters(feature, screen) {
|
|
99
|
+
const declared = feature.entities?.[screen.entity]?.namedFilters ?? {};
|
|
100
|
+
collectBootRuleViolations(screen.namedFilters ?? [], (ref) => {
|
|
101
|
+
// skip: id is declared on the entity
|
|
102
|
+
if (Object.hasOwn(declared, ref.id))
|
|
103
|
+
return;
|
|
104
|
+
throw bootRuleError(ENTITY_LIST_RULES.unknownNamedFilter, {
|
|
105
|
+
message: `${screenPrefix(feature, screen)}: namedFilters id "${ref.id}" is not declared in entity "${screen.entity}" namedFilters. Known: ${Object.keys(declared).sort().join(", ") || "(none)"}.`,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
98
109
|
const ENTITY_LIST_CHECKS = [
|
|
99
110
|
checkSearchable,
|
|
100
111
|
checkDefaultSortRequired,
|
|
101
112
|
checkDefaultSortColumn,
|
|
102
113
|
checkDetailNavigate,
|
|
103
114
|
checkColumns,
|
|
115
|
+
checkNamedFilters,
|
|
104
116
|
];
|
|
105
117
|
export function validateEntityListScreens(features) {
|
|
106
118
|
const screens = features.flatMap((feature) => Object.values(feature.screens).flatMap((screen) => screen.type === "entityList" ? [{ feature, screen }] : []));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const NAMED_FILTER_RULES: {
|
|
2
|
+
readonly projectionQueryAcceptsNamedFilter: {
|
|
3
|
+
readonly id: "named-filter/projection-query-accepts-named-filter";
|
|
4
|
+
readonly summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.";
|
|
5
|
+
readonly fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.";
|
|
6
|
+
};
|
|
7
|
+
readonly statNavigateTarget: {
|
|
8
|
+
readonly id: "named-filter/stat-navigate-target";
|
|
9
|
+
readonly summary: "A stat navigate.screen resolves to an entityList or projectionList screen.";
|
|
10
|
+
readonly fix: "Point navigate.screen at a registered list screen.";
|
|
11
|
+
};
|
|
12
|
+
readonly statNavigateFilterDeclared: {
|
|
13
|
+
readonly id: "named-filter/stat-navigate-filter-declared";
|
|
14
|
+
readonly summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.";
|
|
15
|
+
readonly fix: "Add { id, label } to namedFilters of the target screen.";
|
|
16
|
+
};
|
|
17
|
+
readonly statNavigateSameQuery: {
|
|
18
|
+
readonly id: "named-filter/stat-navigate-same-query";
|
|
19
|
+
readonly summary: "A stat with navigate counts with the target list's own query.";
|
|
20
|
+
readonly fix: "Set the stat query to the target list's query.";
|
|
21
|
+
};
|
|
22
|
+
readonly statNavigateSameNamedFilter: {
|
|
23
|
+
readonly id: "named-filter/stat-navigate-same-named-filter";
|
|
24
|
+
readonly summary: "A stat's params.namedFilter equals navigate.namedFilter.";
|
|
25
|
+
readonly fix: "Set params.namedFilter to the same id as navigate.namedFilter.";
|
|
26
|
+
};
|
|
27
|
+
readonly statNavigateStaticFilter: {
|
|
28
|
+
readonly id: "named-filter/stat-navigate-static-filter";
|
|
29
|
+
readonly summary: "A stat repeats the target screen's static filter in params.filter.";
|
|
30
|
+
readonly fix: "Set params.filter to the target screen's filter.";
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const NAMED_FILTER_RULES = {
|
|
2
|
+
projectionQueryAcceptsNamedFilter: {
|
|
3
|
+
id: "named-filter/projection-query-accepts-named-filter",
|
|
4
|
+
summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.",
|
|
5
|
+
fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.",
|
|
6
|
+
},
|
|
7
|
+
statNavigateTarget: {
|
|
8
|
+
id: "named-filter/stat-navigate-target",
|
|
9
|
+
summary: "A stat navigate.screen resolves to an entityList or projectionList screen.",
|
|
10
|
+
fix: "Point navigate.screen at a registered list screen.",
|
|
11
|
+
},
|
|
12
|
+
statNavigateFilterDeclared: {
|
|
13
|
+
id: "named-filter/stat-navigate-filter-declared",
|
|
14
|
+
summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.",
|
|
15
|
+
fix: "Add { id, label } to namedFilters of the target screen.",
|
|
16
|
+
},
|
|
17
|
+
statNavigateSameQuery: {
|
|
18
|
+
id: "named-filter/stat-navigate-same-query",
|
|
19
|
+
summary: "A stat with navigate counts with the target list's own query.",
|
|
20
|
+
fix: "Set the stat query to the target list's query.",
|
|
21
|
+
},
|
|
22
|
+
statNavigateSameNamedFilter: {
|
|
23
|
+
id: "named-filter/stat-navigate-same-named-filter",
|
|
24
|
+
summary: "A stat's params.namedFilter equals navigate.namedFilter.",
|
|
25
|
+
fix: "Set params.namedFilter to the same id as navigate.namedFilter.",
|
|
26
|
+
},
|
|
27
|
+
statNavigateStaticFilter: {
|
|
28
|
+
id: "named-filter/stat-navigate-static-filter",
|
|
29
|
+
summary: "A stat repeats the target screen's static filter in params.filter.",
|
|
30
|
+
fix: "Set params.filter to the target screen's filter.",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
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 { bootRuleError } from "./boot-rule.js";
|
|
4
5
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
|
|
5
6
|
import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
|
|
7
|
+
import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
|
|
6
8
|
import { findEntity } from "./parent-ref.js";
|
|
7
9
|
import { getZodObjectShape } from "./zod-shape.js";
|
|
8
10
|
// Sibling to entity-list-screens.ts rather than an extension of it:
|
|
@@ -66,6 +68,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
|
|
|
66
68
|
}
|
|
67
69
|
validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
|
|
68
70
|
validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
|
|
71
|
+
validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema);
|
|
69
72
|
validateListTableAggregateQuery({ prefix, ...screen, handler, featureMap });
|
|
70
73
|
}
|
|
71
74
|
// filter sends `payload.filter` — same 422 footgun as facets below
|
|
@@ -78,6 +81,16 @@ function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
|
|
|
78
81
|
`schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
|
|
79
82
|
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
|
|
80
83
|
}
|
|
84
|
+
// namedFilters send `payload.namedFilter` — same 422 footgun as filter/facets.
|
|
85
|
+
function validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema) {
|
|
86
|
+
// skip: no named filters declared, or the schema already accepts the key — nothing to reject.
|
|
87
|
+
if (screen.namedFilters === undefined || schemaAccepts(schema, "namedFilter"))
|
|
88
|
+
return;
|
|
89
|
+
throw bootRuleError(NAMED_FILTER_RULES.projectionQueryAcceptsNamedFilter, {
|
|
90
|
+
message: `${prefix}: declares namedFilters but query "${screen.query}" has no "namedFilter" parameter in its ` +
|
|
91
|
+
`Zod schema — add namedFilter: z.string().optional() to the handler's schema and resolve it with resolveNamedListFilters.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
81
94
|
// facets send `payload.filters` — same failure mode as search/sort:
|
|
82
95
|
// definePagedQueryHandler doesn't auto-merge params into the
|
|
83
96
|
// handler's own Zod schema, so a declared facet would 422 on every query
|
|
@@ -45,6 +45,7 @@ export declare const entityListSchema: z.ZodObject<{
|
|
|
45
45
|
}>;
|
|
46
46
|
value: z.ZodUnknown;
|
|
47
47
|
}, z.core.$strip>>>;
|
|
48
|
+
namedFilter: z.ZodOptional<z.ZodString>;
|
|
48
49
|
aggregate: z.ZodOptional<z.ZodObject<{
|
|
49
50
|
groupBy: z.ZodOptional<z.ZodString>;
|
|
50
51
|
measures: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -15,6 +15,7 @@ import { entityListRuntime, parentVisibilityFor } from "./entity-list-runtime-op
|
|
|
15
15
|
import { assertReason } from "./escape-hatch-reason.js";
|
|
16
16
|
import { dropEmptyWriteOnlyValues } from "./field-access.js";
|
|
17
17
|
import { instructionFieldNames } from "./instruction-fields.js";
|
|
18
|
+
import { resolveNamedListFilters } from "./resolve-named-list-filters.js";
|
|
18
19
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
19
20
|
import { parseTenantId } from "./types/identifiers.js";
|
|
20
21
|
// Convention-based handler factories for event-sourced aggregates.
|
|
@@ -109,6 +110,8 @@ export const entityListSchema = z.object({
|
|
|
109
110
|
value: z.unknown(),
|
|
110
111
|
}))
|
|
111
112
|
.optional(),
|
|
113
|
+
// Entity-defined work-queue rule (EntityDefinition.namedFilters); its conditions are appended to `filters`.
|
|
114
|
+
namedFilter: z.string().min(1).max(100).optional(),
|
|
112
115
|
aggregate: listAggregatePayloadSchema.optional(),
|
|
113
116
|
});
|
|
114
117
|
function parseHandlerName(name, validVerbs) {
|
|
@@ -398,7 +401,16 @@ export function defineEntityQueryHandler(name, entity, options) {
|
|
|
398
401
|
// against Meilisearch/InMem at runtime (remote combobox search). The
|
|
399
402
|
// executor is built at definition time and cannot know the adapter, so the
|
|
400
403
|
// runtime override supplies it.
|
|
401
|
-
const
|
|
404
|
+
const requestPayload = query.payload; // @cast-boundary engine-payload
|
|
405
|
+
const listPayload = requestPayload.namedFilter === undefined
|
|
406
|
+
? requestPayload
|
|
407
|
+
: {
|
|
408
|
+
...requestPayload,
|
|
409
|
+
filters: [
|
|
410
|
+
...(requestPayload.filters ?? []),
|
|
411
|
+
...(await resolveNamedListFilters(ctx, query.user, entityName, requestPayload.namedFilter)),
|
|
412
|
+
],
|
|
413
|
+
};
|
|
402
414
|
const db = dbFor(ctx);
|
|
403
415
|
const { runtimeOptions, eagerLoadViewer, resolveEntity } = entityListRuntime(ctx, entityName, query.user);
|
|
404
416
|
if (listPayload.aggregate !== undefined) {
|
|
@@ -208,6 +208,8 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
|
|
|
208
208
|
readonly readGate?: EntityDefinition["readGate"];
|
|
209
209
|
readonly retention?: RetentionDef;
|
|
210
210
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
211
|
+
/** See EntityDefinition.namedFilters. */
|
|
212
|
+
readonly namedFilters?: EntityDefinition["namedFilters"];
|
|
211
213
|
readonly tenancy?: T;
|
|
212
214
|
/** See EntityDefinition.transferable. */
|
|
213
215
|
readonly transferable?: boolean;
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -80,6 +80,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.ts"
|
|
|
80
80
|
export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.ts";
|
|
81
81
|
export { readClaim } from "./read-claim.ts";
|
|
82
82
|
export { createRegistry } from "./registry.ts";
|
|
83
|
+
export { resolveNamedListFilters } from "./resolve-named-list-filters.ts";
|
|
83
84
|
export type { AssignableAppRoles } from "./role-assignment.ts";
|
|
84
85
|
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.ts";
|
|
85
86
|
export { runPipeline, runStepList } from "./run-pipeline.ts";
|
package/dist/engine/index.js
CHANGED
|
@@ -64,6 +64,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.js"
|
|
|
64
64
|
export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
65
65
|
export { readClaim } from "./read-claim.js";
|
|
66
66
|
export { createRegistry } from "./registry.js";
|
|
67
|
+
export { resolveNamedListFilters } from "./resolve-named-list-filters.js";
|
|
67
68
|
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
68
69
|
export { runPipeline, runStepList } from "./run-pipeline.js";
|
|
69
70
|
export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { NamedListFilterCondition } from "@cosmicdrift/kumiko-types/named-list-filters";
|
|
2
|
+
import type { HandlerContext, SessionUser } from "./types/index.ts";
|
|
3
|
+
export declare function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<readonly NamedListFilterCondition[]>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AccessDeniedError, ValidationError } from "../errors/index.js";
|
|
2
|
+
import { hasAccess } from "./access.js";
|
|
3
|
+
export async function resolveNamedListFilters(ctx, user, entityName, name) {
|
|
4
|
+
const registered = ctx.registry.getEntity(entityName)?.namedFilters ?? {};
|
|
5
|
+
const def = Object.hasOwn(registered, name) ? registered[name] : undefined;
|
|
6
|
+
if (def === undefined) {
|
|
7
|
+
throw new ValidationError({
|
|
8
|
+
fields: [
|
|
9
|
+
{
|
|
10
|
+
path: "namedFilter",
|
|
11
|
+
code: "unknown_named_filter",
|
|
12
|
+
i18nKey: "errors.validation.invalid_value",
|
|
13
|
+
params: { entity: entityName, namedFilter: name },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (def.access !== undefined && !hasAccess(user, def.access)) {
|
|
19
|
+
throw new AccessDeniedError();
|
|
20
|
+
}
|
|
21
|
+
const conditions = await def.filters({ ctx, user, now: ctx.tz.now() });
|
|
22
|
+
const entityFields = ctx.registry.getEntity(entityName)?.fields ?? {};
|
|
23
|
+
for (const condition of conditions) {
|
|
24
|
+
// Fail closed: a condition on a non-column is dropped by the list filter and would widen the queue.
|
|
25
|
+
if (condition.field !== "id" && !Object.hasOwn(entityFields, condition.field)) {
|
|
26
|
+
throw new Error(`Named filter "${name}" of entity "${entityName}" filters on unknown field "${condition.field}".`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return conditions;
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.403.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
"./package.json": "./package.json"
|
|
204
204
|
},
|
|
205
205
|
"dependencies": {
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.403.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"hono": "^4.13.14",
|
|
209
209
|
"i18next": "^26.1.0",
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
}
|
|
226
226
|
},
|
|
227
227
|
"devDependencies": {
|
|
228
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
228
|
+
"@cosmicdrift/kumiko-testing": "0.403.0",
|
|
229
229
|
"bun-types": "^1.4.4",
|
|
230
230
|
"fflate": "^0.8.3",
|
|
231
231
|
"meilisearch": "^0.58.0",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.403.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Entities define named list filters",
|
|
6
|
+
"detail": "createEntity takes namedFilters, a record of { access?, filters(scope) } where filters returns ListFilter conditions (sync or async). The default list query accepts namedFilter and appends the resolved conditions to filters, on the row path and the aggregate path. An unknown name is a validation error on namedFilter, a failed access rule is access_denied. resolveNamedListFilters is exported from the engine entry. The definition is function-valued and never reaches the client schema."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.402.0",
|
|
4
10
|
"type": "improvement",
|