@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +319 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +215 -0
- package/agents/examples/ledger-invoicing.md +236 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +250 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +418 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +75 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/custom-handlers. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# custom-handlers
|
|
4
|
+
|
|
5
|
+
Beyond CRUD — when your domain refuses to fit a table.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Custom handlers
|
|
10
|
+
|
|
11
|
+
Replace the default CRUD with handler bodies that carry business rules.
|
|
12
|
+
This recipe ships a `counter` entity with three custom write handlers
|
|
13
|
+
(create, increment with read-modify-write, reset with last-writer-wins)
|
|
14
|
+
plus a custom query that filters the projection.
|
|
15
|
+
|
|
16
|
+
The point is to show the **boundary**: where the entity executor stops
|
|
17
|
+
doing work for you and where your code has to take over. The framework
|
|
18
|
+
gives you `createEntityExecutor` so the executor's `create` / `update` /
|
|
19
|
+
`detail` / `list` are still one-liners — only the surrounding business
|
|
20
|
+
logic is yours.
|
|
21
|
+
|
|
22
|
+
## What it shows
|
|
23
|
+
|
|
24
|
+
- **`r.writeHandler(name, schema, handler, options)`** — the positional
|
|
25
|
+
inline form, useful when the handler body is short and stays close to
|
|
26
|
+
its registration.
|
|
27
|
+
- **Read-modify-write with optimistic locking** — `increment` reads the
|
|
28
|
+
current count, computes the new one, and updates with the version it
|
|
29
|
+
read. Two concurrent increments cannot both succeed.
|
|
30
|
+
- **`skipOptimisticLock` for last-writer-wins** — `reset` deliberately
|
|
31
|
+
bypasses the version check because the operation is admin-driven and
|
|
32
|
+
resetting twice is semantically the same as resetting once.
|
|
33
|
+
- **Custom queries** — `counter:active` passes a server-side `filter`
|
|
34
|
+
(`eq`/`ne`/`lt`/`gt`/`lte`/`gte`/`in`) and `totalCount` to the executor's
|
|
35
|
+
list. Never filter a page in TypeScript: list is paginated, so rows after
|
|
36
|
+
the first page would be missed.
|
|
37
|
+
- **`failNotFound` helper** — typed error response for the 404 case
|
|
38
|
+
without a hand-built `KumikoError` subclass.
|
|
39
|
+
|
|
40
|
+
## When to reach for it
|
|
41
|
+
|
|
42
|
+
You have an entity but the default CRUD doesn't capture what your
|
|
43
|
+
business rule actually does. The default `update` blindly writes the
|
|
44
|
+
payload; your `increment` needs to read first, decide, and write. That's
|
|
45
|
+
the line.
|
|
46
|
+
|
|
47
|
+
## Source
|
|
48
|
+
|
|
49
|
+
The whole feature lives in `src/feature.ts` (~100 lines). Integration
|
|
50
|
+
tests cover the optimistic-lock case, the skip-lock case, and the
|
|
51
|
+
custom-filter query.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bun kumiko test integration samples/custom-handlers
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Source files
|
|
58
|
+
|
|
59
|
+
### src/__tests__/feature.integration.test.ts
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// Custom Handlers Sample — Integration Test
|
|
63
|
+
// Proves: custom business logic in handlers, payload transformation, custom queries
|
|
64
|
+
|
|
65
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
66
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
67
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
68
|
+
import {
|
|
69
|
+
createTestUser,
|
|
70
|
+
setupTestStack,
|
|
71
|
+
type TestStack,
|
|
72
|
+
TestUsers,
|
|
73
|
+
testTenantId,
|
|
74
|
+
unsafeCreateEntityTable,
|
|
75
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
76
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
77
|
+
import { counterEntity, counterFeature } from "../feature";
|
|
78
|
+
|
|
79
|
+
let stack: TestStack;
|
|
80
|
+
|
|
81
|
+
const admin = TestUsers.admin;
|
|
82
|
+
const user = createTestUser({ id: 2, roles: ["User"] });
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
stack = await setupTestStack({ features: [counterFeature] });
|
|
86
|
+
await unsafeCreateEntityTable(stack.db, counterEntity);
|
|
87
|
+
await createEventsTable(stack.db);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(async () => {
|
|
91
|
+
await stack.cleanup();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
stack.events.reset();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("custom write handler: increment", () => {
|
|
99
|
+
test("increment adds to existing count", async () => {
|
|
100
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
101
|
+
"counters:write:counter:create",
|
|
102
|
+
{
|
|
103
|
+
name: "page-views",
|
|
104
|
+
},
|
|
105
|
+
admin,
|
|
106
|
+
);
|
|
107
|
+
expect(created.data["count"]).toBe(0);
|
|
108
|
+
|
|
109
|
+
const incremented = await stack.http.writeOk<SaveContext>(
|
|
110
|
+
"counters:write:counter:increment",
|
|
111
|
+
{
|
|
112
|
+
id: created.id,
|
|
113
|
+
amount: 5,
|
|
114
|
+
},
|
|
115
|
+
user,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(incremented.data["count"]).toBe(5);
|
|
119
|
+
expect(incremented.data["lastIncrementedBy"]).toBe(`user:${user.id}`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("multiple increments accumulate", async () => {
|
|
123
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
124
|
+
"counters:write:counter:create",
|
|
125
|
+
{
|
|
126
|
+
name: "clicks",
|
|
127
|
+
},
|
|
128
|
+
admin,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await stack.http.writeOk<SaveContext>(
|
|
132
|
+
"counters:write:counter:increment",
|
|
133
|
+
{
|
|
134
|
+
id: created.id,
|
|
135
|
+
amount: 3,
|
|
136
|
+
},
|
|
137
|
+
user,
|
|
138
|
+
);
|
|
139
|
+
const result = await stack.http.writeOk<SaveContext>(
|
|
140
|
+
"counters:write:counter:increment",
|
|
141
|
+
{
|
|
142
|
+
id: created.id,
|
|
143
|
+
amount: 7,
|
|
144
|
+
},
|
|
145
|
+
user,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(result.data["count"]).toBe(10);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("increment validates max amount", async () => {
|
|
152
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
153
|
+
"counters:write:counter:create",
|
|
154
|
+
{
|
|
155
|
+
name: "guarded",
|
|
156
|
+
},
|
|
157
|
+
admin,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const error = await stack.http.writeErr(
|
|
161
|
+
"counters:write:counter:increment",
|
|
162
|
+
{
|
|
163
|
+
id: created.id,
|
|
164
|
+
amount: 101,
|
|
165
|
+
},
|
|
166
|
+
user,
|
|
167
|
+
);
|
|
168
|
+
expectErrorIncludes(error, "validation");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("increment on non-existent returns error", async () => {
|
|
172
|
+
const error = await stack.http.writeErr(
|
|
173
|
+
"counters:write:counter:increment",
|
|
174
|
+
{
|
|
175
|
+
id: "00000000-0000-4000-8000-000000000999",
|
|
176
|
+
amount: 1,
|
|
177
|
+
},
|
|
178
|
+
user,
|
|
179
|
+
);
|
|
180
|
+
expect(error.code).toBe("not_found");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("custom write handler: reset", () => {
|
|
185
|
+
test("reset sets count back to zero", async () => {
|
|
186
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
187
|
+
"counters:write:counter:create",
|
|
188
|
+
{
|
|
189
|
+
name: "resettable",
|
|
190
|
+
},
|
|
191
|
+
admin,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await stack.http.writeOk<SaveContext>(
|
|
195
|
+
"counters:write:counter:increment",
|
|
196
|
+
{
|
|
197
|
+
id: created.id,
|
|
198
|
+
amount: 42,
|
|
199
|
+
},
|
|
200
|
+
user,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const reset = await stack.http.writeOk<SaveContext>(
|
|
204
|
+
"counters:write:counter:reset",
|
|
205
|
+
{
|
|
206
|
+
id: created.id,
|
|
207
|
+
},
|
|
208
|
+
admin,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(reset.data["count"]).toBe(0);
|
|
212
|
+
expect(reset.data["lastIncrementedBy"]).toBe("");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("only Admin can reset", async () => {
|
|
216
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
217
|
+
"counters:write:counter:create",
|
|
218
|
+
{
|
|
219
|
+
name: "admin-only-reset",
|
|
220
|
+
},
|
|
221
|
+
admin,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const error = await stack.http.writeErr(
|
|
225
|
+
"counters:write:counter:reset",
|
|
226
|
+
{
|
|
227
|
+
id: created.id,
|
|
228
|
+
},
|
|
229
|
+
user,
|
|
230
|
+
);
|
|
231
|
+
expect(error.code).toBe("access_denied");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("custom query handler: active counters", () => {
|
|
236
|
+
// Own tenants: the total assertions would otherwise count counters created by other tests.
|
|
237
|
+
const activeAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(11) });
|
|
238
|
+
const activeUser = createTestUser({ id: 2, roles: ["User"], tenantId: testTenantId(11) });
|
|
239
|
+
const otherAdmin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(12) });
|
|
240
|
+
|
|
241
|
+
test("filters counters by minimum count", async () => {
|
|
242
|
+
const c1 = await stack.http.writeOk<SaveContext>(
|
|
243
|
+
"counters:write:counter:create",
|
|
244
|
+
{ name: "active-a" },
|
|
245
|
+
activeAdmin,
|
|
246
|
+
);
|
|
247
|
+
const c2 = await stack.http.writeOk<SaveContext>(
|
|
248
|
+
"counters:write:counter:create",
|
|
249
|
+
{ name: "active-b" },
|
|
250
|
+
activeAdmin,
|
|
251
|
+
);
|
|
252
|
+
await stack.http.writeOk<SaveContext>(
|
|
253
|
+
"counters:write:counter:create",
|
|
254
|
+
{ name: "inactive" },
|
|
255
|
+
activeAdmin,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
await stack.http.writeOk<SaveContext>(
|
|
259
|
+
"counters:write:counter:increment",
|
|
260
|
+
{ id: c1.id, amount: 10 },
|
|
261
|
+
activeUser,
|
|
262
|
+
);
|
|
263
|
+
await stack.http.writeOk<SaveContext>(
|
|
264
|
+
"counters:write:counter:increment",
|
|
265
|
+
{ id: c2.id, amount: 5 },
|
|
266
|
+
activeUser,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const result = await stack.http.queryOk<{
|
|
270
|
+
rows: Record<string, unknown>[];
|
|
271
|
+
nextCursor: string | null;
|
|
272
|
+
total?: number;
|
|
273
|
+
}>("counters:query:counter:active", { minCount: 5 }, activeAdmin);
|
|
274
|
+
|
|
275
|
+
const names = result.rows.map((r) => r["name"]);
|
|
276
|
+
expect(names).toContain("active-a");
|
|
277
|
+
expect(names).toContain("active-b");
|
|
278
|
+
expect(names).not.toContain("inactive");
|
|
279
|
+
expect(result.total).toBe(2);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("total and nextCursor describe the full filtered set", async () => {
|
|
283
|
+
for (const name of ["total-a", "total-b", "total-c"]) {
|
|
284
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
285
|
+
"counters:write:counter:create",
|
|
286
|
+
{ name },
|
|
287
|
+
otherAdmin,
|
|
288
|
+
);
|
|
289
|
+
await stack.http.writeOk<SaveContext>(
|
|
290
|
+
"counters:write:counter:increment",
|
|
291
|
+
{ id: created.id, amount: 1 },
|
|
292
|
+
otherAdmin,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
await stack.http.writeOk<SaveContext>(
|
|
296
|
+
"counters:write:counter:create",
|
|
297
|
+
{ name: "total-untouched" },
|
|
298
|
+
otherAdmin,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const result = await stack.http.queryOk<{
|
|
302
|
+
rows: Record<string, unknown>[];
|
|
303
|
+
nextCursor: string | null;
|
|
304
|
+
total?: number;
|
|
305
|
+
}>("counters:query:counter:active", { minCount: 1 }, otherAdmin);
|
|
306
|
+
|
|
307
|
+
expect(result.rows).toHaveLength(3);
|
|
308
|
+
expect(result.total).toBe(3);
|
|
309
|
+
expect(result.nextCursor).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### src/feature.ts
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// Custom Handlers Sample
|
|
318
|
+
// Shows: writeHandler with business logic, queryHandler with a server-side filter,
|
|
319
|
+
// handler that modifies payload before DB write.
|
|
320
|
+
|
|
321
|
+
import {
|
|
322
|
+
createEntity,
|
|
323
|
+
createEntityExecutor,
|
|
324
|
+
createNumberField,
|
|
325
|
+
createTextField,
|
|
326
|
+
defineFeature,
|
|
327
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
328
|
+
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
329
|
+
import { z } from "zod";
|
|
330
|
+
|
|
331
|
+
// detail() returns an untyped row; parse the two fields the increment needs once.
|
|
332
|
+
const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
|
|
333
|
+
|
|
334
|
+
export const counterEntity = createEntity({
|
|
335
|
+
table: "read_sample_counters",
|
|
336
|
+
fields: {
|
|
337
|
+
name: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
338
|
+
count: createNumberField({ default: 0 }),
|
|
339
|
+
lastIncrementedBy: createTextField({ personal: "ref" }),
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
export const counterFeature = defineFeature("counters", (r) => {
|
|
344
|
+
r.entity("counter", counterEntity);
|
|
345
|
+
|
|
346
|
+
// createEntityExecutor bundles buildEntityTable + createEventStoreExecutor —
|
|
347
|
+
// the same pair every custom write-handler opens with. Collapses 3 lines
|
|
348
|
+
// + the { entityName } bookkeeping into one destructure.
|
|
349
|
+
const { executor: counterExecutor } = createEntityExecutor("counter", counterEntity);
|
|
350
|
+
|
|
351
|
+
// Standard create
|
|
352
|
+
r.writeHandler(
|
|
353
|
+
"counter:create",
|
|
354
|
+
z.object({ name: z.string().min(1) }),
|
|
355
|
+
async (event, ctx) =>
|
|
356
|
+
counterExecutor.create({ ...event.payload, count: 0 }, event.user, ctx.db),
|
|
357
|
+
{ access: { roles: ["Admin"] } },
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
// Custom handler: increment with business logic
|
|
361
|
+
r.writeHandler(
|
|
362
|
+
"counter:increment",
|
|
363
|
+
z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
|
|
364
|
+
async (event, ctx) => {
|
|
365
|
+
const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db);
|
|
366
|
+
if (!current) {
|
|
367
|
+
return failNotFound("counter", event.payload.id);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const { count, version } = counterVersionRowSchema.parse(current);
|
|
371
|
+
const newCount = count + event.payload.amount;
|
|
372
|
+
// Read-modify-write on counter: use the version we just read so two
|
|
373
|
+
// concurrent increments don't clobber each other's changes.
|
|
374
|
+
return counterExecutor.update(
|
|
375
|
+
{
|
|
376
|
+
id: event.payload.id,
|
|
377
|
+
version,
|
|
378
|
+
changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
|
|
379
|
+
},
|
|
380
|
+
event.user,
|
|
381
|
+
ctx.db,
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Custom handler: reset to zero
|
|
388
|
+
r.writeHandler(
|
|
389
|
+
"counter:reset",
|
|
390
|
+
z.object({ id: z.uuid() }),
|
|
391
|
+
async (event, ctx) =>
|
|
392
|
+
// Admin reset: last-writer-wins is intentional — there's no useful
|
|
393
|
+
// concurrent-reset race to guard against.
|
|
394
|
+
counterExecutor.update(
|
|
395
|
+
{ id: event.payload.id, changes: { count: 0, lastIncrementedBy: "" } },
|
|
396
|
+
event.user,
|
|
397
|
+
ctx.db,
|
|
398
|
+
{ skipOptimisticLock: true },
|
|
399
|
+
),
|
|
400
|
+
{ access: { roles: ["Admin"] } },
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// Custom query: only counters above a threshold. list() returns one page
|
|
404
|
+
// (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
|
|
405
|
+
// page 1; filter in the query instead. totalCount counts over the whole WHERE.
|
|
406
|
+
r.queryHandler(
|
|
407
|
+
"counter:active",
|
|
408
|
+
z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
|
|
409
|
+
async (query, ctx) =>
|
|
410
|
+
counterExecutor.list(
|
|
411
|
+
{
|
|
412
|
+
filter: { field: "count", op: "gte", value: query.payload.minCount },
|
|
413
|
+
totalCount: true,
|
|
414
|
+
cursor: query.payload.cursor,
|
|
415
|
+
},
|
|
416
|
+
query.user,
|
|
417
|
+
ctx.db,
|
|
418
|
+
),
|
|
419
|
+
{
|
|
420
|
+
access: {
|
|
421
|
+
openToAll: {
|
|
422
|
+
reason:
|
|
423
|
+
"any signed-in user may list active counters; the sample has no per-user counter ownership",
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// Standard detail
|
|
430
|
+
r.queryHandler(
|
|
431
|
+
"counter:detail",
|
|
432
|
+
z.object({ id: z.uuid() }),
|
|
433
|
+
async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db),
|
|
434
|
+
{
|
|
435
|
+
access: {
|
|
436
|
+
openToAll: {
|
|
437
|
+
reason:
|
|
438
|
+
"any signed-in user may read a counter by id; the sample has no per-user counter ownership",
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
```
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/dashboard-stats. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# dashboard-stats
|
|
4
|
+
|
|
5
|
+
Key figures on a dashboard: stat panels backed by one counting query.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Dashboard stats
|
|
10
|
+
|
|
11
|
+
Key figures on a dashboard. This recipe ships a `ticket` entity, one query
|
|
12
|
+
handler that returns the figures as a flat object, and a `dashboard` screen
|
|
13
|
+
with a stat group (open, closed) and a single stat (total).
|
|
14
|
+
|
|
15
|
+
The point is to show how the three pieces connect: the screen only names a
|
|
16
|
+
query and the fields to read, the query does the counting.
|
|
17
|
+
|
|
18
|
+
## What it shows
|
|
19
|
+
|
|
20
|
+
- **Panels reference a query by qualified name**: each `stat` carries
|
|
21
|
+
`query: "tickets:query:ticket:stats"`. The dashboard calls that handler and
|
|
22
|
+
renders the result.
|
|
23
|
+
- **`valueField` picks a field from a flat result**: the handler returns
|
|
24
|
+
`{ open, closed, total }`, and each stat names one of those keys.
|
|
25
|
+
- **`stat-group` and `stat`**: a group shows several stats side by side under
|
|
26
|
+
one title, a top-level `stat` stands alone.
|
|
27
|
+
- **Counting happens in the database**: the handler calls the executor's
|
|
28
|
+
`list` with `filter`, `limit: 1` and `totalCount: true`. The total is counted
|
|
29
|
+
over the whole WHERE clause, so no rows are loaded just to be counted.
|
|
30
|
+
- **Labels are translation keys**: declared with `r.translations`.
|
|
31
|
+
|
|
32
|
+
## When to reach for it
|
|
33
|
+
|
|
34
|
+
You want a landing screen with a few numbers: open items, overdue invoices,
|
|
35
|
+
this month's revenue. Write one query per screen that returns every figure,
|
|
36
|
+
and let the stats pick their field.
|
|
37
|
+
|
|
38
|
+
## Source
|
|
39
|
+
|
|
40
|
+
The whole feature lives in `src/feature.ts` (~100 lines). The integration test
|
|
41
|
+
checks that every stat reads a field the query returns, and that the query
|
|
42
|
+
counts open and closed tickets correctly.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bun kumiko test integration samples/dashboard-stats
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Source files
|
|
49
|
+
|
|
50
|
+
### src/__tests__/feature.integration.test.ts
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// Dashboard Stats Sample, integration test.
|
|
54
|
+
// Proves: the dashboard screen boots, its panels point at a registered query,
|
|
55
|
+
// and that query counts per status in the database.
|
|
56
|
+
|
|
57
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
58
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
59
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
60
|
+
import {
|
|
61
|
+
createTestUser,
|
|
62
|
+
setupTestStack,
|
|
63
|
+
type TestStack,
|
|
64
|
+
testTenantId,
|
|
65
|
+
unsafeCreateEntityTable,
|
|
66
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
67
|
+
import { TICKET_STATS_QUERY, ticketEntity, ticketFeature } from "../feature";
|
|
68
|
+
|
|
69
|
+
let stack: TestStack;
|
|
70
|
+
|
|
71
|
+
const admin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(31) });
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
stack = await setupTestStack({ features: [ticketFeature] });
|
|
75
|
+
await unsafeCreateEntityTable(stack.db, ticketEntity);
|
|
76
|
+
await createEventsTable(stack.db);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await stack.cleanup();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("dashboard screen", () => {
|
|
84
|
+
test("boots, and every stat reads a field the stats query returns", async () => {
|
|
85
|
+
const screen = ticketFeature.screens["ticket-dashboard"];
|
|
86
|
+
if (screen?.type !== "dashboard") throw new Error("ticket-dashboard is not a dashboard screen");
|
|
87
|
+
|
|
88
|
+
const stats = screen.panels.flatMap((panel) =>
|
|
89
|
+
panel.kind === "stat-group" ? panel.stats : panel.kind === "stat" ? [panel] : [],
|
|
90
|
+
);
|
|
91
|
+
expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total"]);
|
|
92
|
+
for (const stat of stats) expect(stat.query).toBe(TICKET_STATS_QUERY);
|
|
93
|
+
|
|
94
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
|
|
95
|
+
for (const stat of stats) expect(result).toHaveProperty(stat.valueField);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("ticket:stats", () => {
|
|
100
|
+
test("counts open and closed tickets in the database", async () => {
|
|
101
|
+
for (const [title, status] of [
|
|
102
|
+
["a", "open"],
|
|
103
|
+
["b", "open"],
|
|
104
|
+
["c", "open"],
|
|
105
|
+
["d", "closed"],
|
|
106
|
+
["e", "closed"],
|
|
107
|
+
] as const) {
|
|
108
|
+
await stack.http.writeOk<SaveContext>(
|
|
109
|
+
"tickets:write:ticket:create",
|
|
110
|
+
{ title, status },
|
|
111
|
+
admin,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
|
|
116
|
+
|
|
117
|
+
expect(result).toEqual({ open: 3, closed: 2, total: 5 });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### src/feature.ts
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// Dashboard Stats Sample
|
|
126
|
+
// Shows: a dashboard screen with a stat-group and a single stat, backed by one
|
|
127
|
+
// query handler that counts in the database.
|
|
128
|
+
|
|
129
|
+
import {
|
|
130
|
+
createEntity,
|
|
131
|
+
createEntityExecutor,
|
|
132
|
+
createSelectField,
|
|
133
|
+
createTextField,
|
|
134
|
+
defineFeature,
|
|
135
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
136
|
+
import { z } from "zod";
|
|
137
|
+
|
|
138
|
+
export const TICKET_STATUSES = ["open", "closed"] as const;
|
|
139
|
+
|
|
140
|
+
export const ticketEntity = createEntity({
|
|
141
|
+
table: "read_sample_tickets",
|
|
142
|
+
fields: {
|
|
143
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
144
|
+
status: createSelectField({ options: TICKET_STATUSES, default: "open", required: true }),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
|
|
149
|
+
|
|
150
|
+
const readerRoles = { roles: ["Admin", "User"] } as const;
|
|
151
|
+
|
|
152
|
+
export const ticketFeature = defineFeature("tickets", (r) => {
|
|
153
|
+
const { executor: ticketExecutor } = createEntityExecutor("ticket", ticketEntity);
|
|
154
|
+
|
|
155
|
+
r.crud("ticket", ticketEntity, {
|
|
156
|
+
write: { access: { roles: ["Admin"] } },
|
|
157
|
+
read: { access: readerRoles },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Flat result: each dashboard stat reads one field via its valueField.
|
|
161
|
+
// limit: 1 keeps the page tiny; totalCount counts over the whole WHERE in
|
|
162
|
+
// the database, so no rows are loaded just to be counted.
|
|
163
|
+
r.queryHandler(
|
|
164
|
+
"ticket:stats",
|
|
165
|
+
z.object({}),
|
|
166
|
+
async (query, ctx) => {
|
|
167
|
+
const countWithStatus = async (status: (typeof TICKET_STATUSES)[number]): Promise<number> => {
|
|
168
|
+
const page = await ticketExecutor.list(
|
|
169
|
+
{ filter: { field: "status", op: "eq", value: status }, limit: 1, totalCount: true },
|
|
170
|
+
query.user,
|
|
171
|
+
ctx.db,
|
|
172
|
+
);
|
|
173
|
+
return page.total ?? 0;
|
|
174
|
+
};
|
|
175
|
+
const [open, closed] = await Promise.all([
|
|
176
|
+
countWithStatus("open"),
|
|
177
|
+
countWithStatus("closed"),
|
|
178
|
+
]);
|
|
179
|
+
return { open, closed, total: open + closed };
|
|
180
|
+
},
|
|
181
|
+
{ access: readerRoles },
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
r.translations({
|
|
185
|
+
keys: {
|
|
186
|
+
"tickets.dashboard.group": { en: "Tickets by status" },
|
|
187
|
+
"tickets.dashboard.open": { en: "Open" },
|
|
188
|
+
"tickets.dashboard.closed": { en: "Closed" },
|
|
189
|
+
"tickets.dashboard.total": { en: "All tickets" },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
r.screen({
|
|
194
|
+
id: "ticket-dashboard",
|
|
195
|
+
type: "dashboard",
|
|
196
|
+
panels: [
|
|
197
|
+
{
|
|
198
|
+
kind: "stat-group",
|
|
199
|
+
id: "by-status",
|
|
200
|
+
label: "tickets.dashboard.group",
|
|
201
|
+
stats: [
|
|
202
|
+
{
|
|
203
|
+
kind: "stat",
|
|
204
|
+
id: "open",
|
|
205
|
+
label: "tickets.dashboard.open",
|
|
206
|
+
query: TICKET_STATS_QUERY,
|
|
207
|
+
valueField: "open",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
kind: "stat",
|
|
211
|
+
id: "closed",
|
|
212
|
+
label: "tickets.dashboard.closed",
|
|
213
|
+
query: TICKET_STATS_QUERY,
|
|
214
|
+
valueField: "closed",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
kind: "stat",
|
|
220
|
+
id: "total",
|
|
221
|
+
label: "tickets.dashboard.total",
|
|
222
|
+
query: TICKET_STATS_QUERY,
|
|
223
|
+
valueField: "total",
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
access: readerRoles,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
```
|