@cosmicdrift/kumiko-samples 0.399.0 → 0.401.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/documents/aggregate-id.ts +20 -0
  4. package/packages/bundled-features/src/documents/capture.ts +125 -0
  5. package/packages/bundled-features/src/documents/constants.ts +31 -0
  6. package/packages/bundled-features/src/documents/ensure-link.ts +35 -0
  7. package/packages/bundled-features/src/documents/entity.ts +109 -0
  8. package/packages/bundled-features/src/documents/executor.ts +13 -0
  9. package/packages/bundled-features/src/documents/expiry.ts +32 -0
  10. package/packages/bundled-features/src/documents/feature.ts +226 -0
  11. package/packages/bundled-features/src/documents/handlers/import.write.ts +157 -0
  12. package/packages/bundled-features/src/documents/handlers/link.write.ts +105 -0
  13. package/packages/bundled-features/src/documents/i18n.ts +49 -0
  14. package/packages/bundled-features/src/documents/index.ts +23 -0
  15. package/packages/bundled-features/src/documents/pending-file-guard.ts +36 -0
  16. package/packages/bundled-features/src/documents/queries/expiry-overview.query.ts +46 -0
  17. package/packages/bundled-features/src/documents/queries/for-record.query.ts +74 -0
  18. package/packages/bundled-features/src/documents/queries/list-page.ts +51 -0
  19. package/packages/bundled-features/src/documents/queries/overview.query.ts +45 -0
  20. package/packages/bundled-features/src/documents/schemas.ts +66 -0
  21. package/packages/bundled-features/src/documents/screens.ts +86 -0
  22. package/packages/bundled-features/src/documents/tab.ts +46 -0
  23. package/packages/bundled-features/src/documents/target-write-check.ts +47 -0
  24. package/packages/bundled-features/src/documents/write-guard.ts +59 -0
  25. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +1 -1
  26. package/packages/bundled-features/src/shared/purge-row-file-refs.ts +5 -4
  27. package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -0
  28. package/samples/apps/styleguide/src/app/seed.ts +102 -1
  29. package/samples/apps/styleguide/src/app/server.ts +2 -2
  30. package/samples/apps/styleguide/src/features/examples/feature.ts +97 -0
  31. package/samples/apps/styleguide/src/features/examples/i18n.ts +47 -0
  32. package/samples/apps/use-all-bundled/feature-manifest.json +30 -1
  33. package/samples/apps/use-all-bundled/kumiko/migrations/0058_documents.rebuild.json +4 -0
  34. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +11 -2
  35. package/samples/apps/use-all-bundled/src/run-config.ts +7 -0
  36. package/samples/recipes/derived-fields-sql/README.md +27 -0
  37. package/samples/recipes/derived-fields-sql/package.json +11 -0
  38. package/samples/recipes/derived-fields-sql/src/feature.ts +98 -0
  39. package/samples/recipes/documents/package.json +12 -0
  40. package/samples/recipes/documents/src/feature.ts +66 -0
  41. package/samples/recipes/inspection-checklist/README.md +27 -0
  42. package/samples/recipes/inspection-checklist/package.json +12 -0
  43. package/samples/recipes/inspection-checklist/src/entities/inspection-plan.ts +24 -0
  44. package/samples/recipes/inspection-checklist/src/entities/inspection-protocol.ts +72 -0
  45. package/samples/recipes/inspection-checklist/src/feature.ts +33 -0
  46. package/samples/recipes/inspection-checklist/src/handlers/create-from-plan.write.ts +38 -0
  47. package/samples/recipes/inspection-checklist/src/screens/inspection-protocol-edit.screen.ts +27 -0
  48. package/samples/recipes/relations/src/feature.ts +13 -3
@@ -0,0 +1,86 @@
1
+ import {
2
+ type AccessRule,
3
+ i18nKey,
4
+ type ProjectionListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { DOCUMENTS_LIST_SCREEN_ID, DocumentsQueries, EXPIRING_FACET_VALUE } from "./constants.ts";
7
+
8
+ // The columns every document list shows; the tab and the standalone screen stay alike.
9
+ export const DOCUMENT_COLUMNS = ["title", "category", "validUntil", "file"] as const;
10
+
11
+ const FIELD_PREFIX = "documents:entity:filed-document:field:";
12
+ const CATEGORY_OPTION_PREFIX = `${FIELD_PREFIX}category:option:`;
13
+
14
+ // A projectionList, not an entityList: the expiry state and its "expiring" filter are computed
15
+ // per request from a moving date window, which an entityList's facets on stored fields cannot
16
+ // express. The overview query still reads through the document list, so the read gate holds.
17
+ export function createDocumentListScreen(
18
+ access: AccessRule,
19
+ categories: readonly string[],
20
+ ): ProjectionListScreenDefinition {
21
+ return {
22
+ id: DOCUMENTS_LIST_SCREEN_ID,
23
+ type: "projectionList",
24
+ query: DocumentsQueries.overview,
25
+ columns: [
26
+ { field: "title", label: `${FIELD_PREFIX}title` },
27
+ {
28
+ field: "category",
29
+ label: `${FIELD_PREFIX}category`,
30
+ renderer: { format: "enumOption", keyPrefix: CATEGORY_OPTION_PREFIX },
31
+ },
32
+ {
33
+ field: "validUntil",
34
+ label: `${FIELD_PREFIX}validUntil`,
35
+ renderer: { format: "date", dateStyle: "medium" },
36
+ },
37
+ {
38
+ field: "expiry",
39
+ label: "documents:list.facet.expiry",
40
+ sortable: false,
41
+ hideOnNarrow: true,
42
+ renderer: { format: "enumOption", keyPrefix: "documents:list.expiry." },
43
+ },
44
+ {
45
+ field: "file",
46
+ label: `${FIELD_PREFIX}file`,
47
+ sortable: false,
48
+ renderer: { format: "file" },
49
+ },
50
+ ],
51
+ pagination: "pages",
52
+ defaultSort: { field: "validUntil", dir: "asc" },
53
+ searchable: true,
54
+ facets: [
55
+ {
56
+ field: "category",
57
+ type: "select",
58
+ label: `${FIELD_PREFIX}category`,
59
+ options: categories.map((value) => ({
60
+ value,
61
+ label: `${CATEGORY_OPTION_PREFIX}${value}`,
62
+ })),
63
+ },
64
+ {
65
+ field: "expiry",
66
+ type: "select",
67
+ label: "documents:list.facet.expiry",
68
+ options: [{ value: EXPIRING_FACET_VALUE, label: "documents:list.facet.expiring" }],
69
+ },
70
+ ],
71
+ rowActions: [
72
+ {
73
+ kind: "writeHandler",
74
+ id: "delete",
75
+ label: i18nKey("kumiko.actions.delete"),
76
+ handler: "documents:write:filed-document:delete",
77
+ payload: { pick: ["id"] },
78
+ confirm: i18nKey("kumiko.actions.delete-confirm"),
79
+ style: "danger",
80
+ },
81
+ ],
82
+ access,
83
+ // The app places it with r.nav("documents:screen:list").
84
+ dormant: true,
85
+ };
86
+ }
@@ -0,0 +1,46 @@
1
+ import type { EditRelatedListSection } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { DashboardStatPanel } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { DOCUMENT_ENTITY, DOCUMENTS_FEATURE_NAME, DocumentsQueries } from "./constants.ts";
4
+ import { forTypeQueryQn } from "./queries/for-record.query.ts";
5
+
6
+ // "Documents" tab for the edit screen of a link target. The tab id is the same on every screen,
7
+ // so deep links like ?tab=documents work across hosts.
8
+ export function documentsTab(entityType: string): EditRelatedListSection {
9
+ return {
10
+ id: "documents",
11
+ kind: "relatedList",
12
+ title: "documents:tab.title",
13
+ query: forTypeQueryQn(entityType),
14
+ entity: `${DOCUMENTS_FEATURE_NAME}:${DOCUMENT_ENTITY}`,
15
+ columns: [
16
+ { field: "title", sortable: true },
17
+ { field: "category", sortable: true },
18
+ { field: "validUntil", sortable: true },
19
+ { field: "file" },
20
+ ],
21
+ searchable: true,
22
+ count: true,
23
+ };
24
+ }
25
+
26
+ // Stat tiles for a dashboard: how many readable documents are expired or about to expire.
27
+ export function documentExpiryPanels(): readonly DashboardStatPanel[] {
28
+ return [
29
+ {
30
+ kind: "stat",
31
+ id: "documents-expired",
32
+ label: "documents:overview.expired",
33
+ query: DocumentsQueries.expiryOverview,
34
+ valueField: "expired",
35
+ tone: "negative",
36
+ },
37
+ {
38
+ kind: "stat",
39
+ id: "documents-expiring",
40
+ label: "documents:overview.expiring",
41
+ query: DocumentsQueries.expiryOverview,
42
+ valueField: "expiring",
43
+ tone: "warn",
44
+ },
45
+ ];
46
+ }
@@ -0,0 +1,47 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntityExecutor,
4
+ hasAccess,
5
+ type Registry,
6
+ type SessionUser,
7
+ toKebab,
8
+ userCanWriteFieldRow,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { parentRowIsVisible } from "../shared/index.ts";
11
+
12
+ function updateHandlerAccessOf(registry: Registry, entityType: string) {
13
+ const suffix = `:write:${toKebab(entityType)}:update`;
14
+ for (const [qn, handler] of registry.getAllWriteHandlers()) {
15
+ if (qn.endsWith(suffix) && registry.getHandlerEntity(qn) === entityType) return handler.access;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ // Linking a document changes what readers of the target see, so it needs what saving the target
21
+ // needs: read visibility, a write gate and its row-level write rule. The gate is the entity's
22
+ // update handler; an entity saved only through custom handlers has none, so its declared write
23
+ // roles stand in, and without either the target stays unlinkable. A target that is not visible
24
+ // answers like a missing one.
25
+ export async function canWriteLinkTarget(
26
+ registry: Registry,
27
+ entityType: string,
28
+ entityId: string,
29
+ user: SessionUser,
30
+ db: TenantDb,
31
+ ): Promise<boolean> {
32
+ if (!(await parentRowIsVisible(registry, entityType, entityId, user, db))) return false;
33
+ const entity = registry.getEntity(entityType);
34
+ if (entity === undefined) return false;
35
+ const updateAccess = updateHandlerAccessOf(registry, entityType);
36
+ if (updateAccess !== undefined) {
37
+ if (!hasAccess(user, updateAccess)) return false;
38
+ } else if (!hasAccess(user, { roles: Object.keys(entity.access?.write ?? {}) })) {
39
+ return false;
40
+ }
41
+ const row = await createEntityExecutor(entityType, entity).executor.detail(
42
+ { id: entityId },
43
+ user,
44
+ db,
45
+ );
46
+ return row !== null && userCanWriteFieldRow(user, entity.access?.write, row, row);
47
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ type DeleteContext,
3
+ type HandlerContext,
4
+ type PreSaveHookFn,
5
+ SYSTEM_ROLE,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { DOCUMENT_ENTITY } from "./constants.ts";
9
+ import type { DocumentExecutors } from "./executor.ts";
10
+ import { canWriteLinkTarget } from "./target-write-check.ts";
11
+
12
+ const LINK_PAGE_SIZE = 200;
13
+
14
+ // Editing or deleting a document changes what every reader of its hosts sees, so it needs what
15
+ // unlink needs: write access to at least one host it is linked to. Admin roles see unlinked
16
+ // documents too and must be able to clean them up, and SYSTEM jobs (import, capture) write on
17
+ // their own authority. A refused caller gets "not found", like a document they cannot read.
18
+ export function createDocumentWriteGuard(
19
+ executors: DocumentExecutors,
20
+ adminRoles: readonly string[],
21
+ ) {
22
+ async function assertMayWrite(documentId: string, ctx: HandlerContext): Promise<void> {
23
+ const { user } = ctx;
24
+ if (user.roles.includes(SYSTEM_ROLE) || user.roles.some((role) => adminRoles.includes(role))) {
25
+ // skip: exempt roles write on their own authority
26
+ return;
27
+ }
28
+ let offset = 0;
29
+ for (;;) {
30
+ const page = await executors.link.list(
31
+ {
32
+ filters: [{ field: "documentId", op: "eq", value: documentId }],
33
+ limit: LINK_PAGE_SIZE,
34
+ offset,
35
+ },
36
+ user,
37
+ ctx.db,
38
+ );
39
+ for (const row of page.rows) {
40
+ const entityType = String(row["entityType"]);
41
+ const entityId = String(row["entityId"]);
42
+ // skip: one writable host is enough
43
+ if (await canWriteLinkTarget(ctx.registry, entityType, entityId, user, ctx.db)) return;
44
+ }
45
+ if (page.rows.length < LINK_PAGE_SIZE) break;
46
+ offset += LINK_PAGE_SIZE;
47
+ }
48
+ throw new NotFoundError(DOCUMENT_ENTITY, documentId);
49
+ }
50
+
51
+ const beforeUpdate: PreSaveHookFn = async (changes, ctx) => {
52
+ if (!ctx.isNew) await assertMayWrite(String(ctx.previous["id"]), ctx);
53
+ return changes;
54
+ };
55
+ const beforeDelete = async (payload: DeleteContext, ctx: HandlerContext): Promise<void> => {
56
+ await assertMayWrite(String(payload.id), ctx);
57
+ };
58
+ return { beforeUpdate, beforeDelete };
59
+ }
@@ -65,7 +65,7 @@ export function createBrandingSettingsScreen(opts: {
65
65
  reason: "is_business_data",
66
66
  }),
67
67
  logoFile: createImageField({
68
- accept: ["image/png", "image/jpeg", "image/webp"],
68
+ accept: ["png", "jpg", "jpeg", "webp"],
69
69
  personal: false,
70
70
  reason: "is_business_data",
71
71
  }),
@@ -1,6 +1,6 @@
1
1
  import { type DbRunner, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { EntityDefinition, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
- import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
3
+ import { fileRefsTable, holdsFileRefs } from "@cosmicdrift/kumiko-framework/files";
4
4
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
 
6
6
  type EntityFields = EntityDefinition["fields"];
@@ -23,8 +23,9 @@ export type FileRefPurgeOutcome = "purged" | "missing_file_storage" | "file_dele
23
23
 
24
24
  const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
25
25
 
26
- // Same set as boot-validator/entity-handler.ts FILE_FIELD_TYPES; the single/multi split
27
- // matters here because only plural fields resolve through file_refs.
26
+ // Same set as boot-validator/entity-handler.ts FILE_FIELD_TYPES plus embedded fields with file
27
+ // sub-fields; the single/multi split matters here because only plural fields resolve through
28
+ // file_refs, bound under the top-level field name.
28
29
  export function planFileFields(
29
30
  fields: EntityFields,
30
31
  fieldNames?: readonly string[],
@@ -34,7 +35,7 @@ export function planFileFields(
34
35
  for (const [name, field] of Object.entries(fields)) {
35
36
  if (fieldNames !== undefined && !fieldNames.includes(name)) continue;
36
37
  if (field.type === "file" || field.type === "image") singleFields.push(name);
37
- else if (field.type === "files" || field.type === "images") multiFields.push(name);
38
+ else if (holdsFileRefs(field)) multiFields.push(name);
38
39
  }
39
40
  return singleFields.length > 0 || multiFields.length > 0
40
41
  ? { singleFields, multiFields }
@@ -21,6 +21,10 @@ export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> =
21
21
  },
22
22
  "user-data-rights:entity:export-job:field:userId": { en: "User" },
23
23
  "user-data-rights:entity:export-job:field:status": { en: "Status" },
24
+ "user-data-rights:entity:export-job:field:status:option:pending": { en: "Pending" },
25
+ "user-data-rights:entity:export-job:field:status:option:running": { en: "Running" },
26
+ "user-data-rights:entity:export-job:field:status:option:done": { en: "Done" },
27
+ "user-data-rights:entity:export-job:field:status:option:failed": { en: "Failed" },
24
28
  "user-data-rights:entity:export-job:field:requestedAt": { en: "Requested" },
25
29
  "user-data-rights:entity:export-job:field:completedAt": { en: "Completed" },
26
30
  "user-data-rights:entity:export-job:field:expiresAt": { en: "Expires" },
@@ -1,9 +1,14 @@
1
1
  import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
2
2
  import { buildEntityTable, createTenantDb, fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
- import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createEntityExecutor, createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ createFileRefFromBytes,
6
+ createTenantFileContext,
7
+ } from "@cosmicdrift/kumiko-framework/files";
4
8
  import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
5
9
 
6
10
  import { demoEntity } from "../features/demo/feature";
11
+ import { inspectionEntity } from "../features/examples/feature";
7
12
 
8
13
  // Gives the list view real rows with status/flag variety. price (money)
9
14
  // stays empty — the money input shows in the edit screen, in the seed we leave
@@ -16,6 +21,8 @@ const STATUSES = ["draft", "review", "published", "archived"] as const;
16
21
  // otherwise change every run.
17
22
  export const FIRST_STYLEGUIDE_ITEM_ID = "00000000-0000-4000-8000-0000000000f1";
18
23
 
24
+ export const FIRST_STYLEGUIDE_INSPECTION_ID = "00000000-0000-4000-8000-0000000000f2";
25
+
19
26
  const demoItemTable = buildEntityTable("item", demoEntity);
20
27
 
21
28
  export const seedStyleguideItems: SeedFn = async (stack) => {
@@ -58,3 +65,97 @@ export const seedStyleguideItems: SeedFn = async (stack) => {
58
65
  }
59
66
  }
60
67
  };
68
+
69
+ const inspectionTable = buildEntityTable("inspection", inspectionEntity);
70
+
71
+ // 1x1 PNG, enough for the thumbnail chain; the bytes only need to be a valid image.
72
+ const SEED_PHOTO_PNG = Uint8Array.from(
73
+ Buffer.from(
74
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
75
+ "base64",
76
+ ),
77
+ );
78
+
79
+ // Fixed id for the inspection-checklist screenshot scenarios. Created through the executor like
80
+ // the first demo item. Photos are real file refs attached to checkpoints.photos, created
81
+ // server-side the way POST /files would (a not_ok checkpoint requires a photo).
82
+ export const seedStyleguideInspection: SeedFn = async (stack) => {
83
+ if (await fetchOne(stack.db, inspectionTable, { id: FIRST_STYLEGUIDE_INSPECTION_ID })) return;
84
+ const tenantId = TestUsers.admin.tenantId;
85
+ const tenantDb = createTenantDb({ db: stack.db, tenantId });
86
+ const resolver = stack.context._fileProviderResolver;
87
+ const files = resolver ? createTenantFileContext(resolver, tenantId) : stack.context.files;
88
+
89
+ const photoIds: string[] = [];
90
+ for (const fileName of ["hose.png", "bearing.png"]) {
91
+ const photo = await createFileRefFromBytes(
92
+ {
93
+ db: tenantDb,
94
+ services: { files },
95
+ registry: stack.registry,
96
+ user: createSystemUser(tenantId),
97
+ },
98
+ {
99
+ fileName,
100
+ mimeType: "image/png",
101
+ data: SEED_PHOTO_PNG,
102
+ attachTo: {
103
+ entityType: "inspection",
104
+ fieldName: "checkpoints",
105
+ subField: "photos",
106
+ entityId: FIRST_STYLEGUIDE_INSPECTION_ID,
107
+ },
108
+ },
109
+ );
110
+ if (!photo.isSuccess) {
111
+ // biome-ignore lint/suspicious/noConsole: dev-seed surfaces failures in the runner log
112
+ console.error("[seed] styleguide inspection photo failed:", photo.error);
113
+ return;
114
+ }
115
+ photoIds.push(photo.data.id);
116
+ }
117
+
118
+ const { executor } = createEntityExecutor("inspection", inspectionEntity);
119
+ const res = await executor.create(
120
+ {
121
+ id: FIRST_STYLEGUIDE_INSPECTION_ID,
122
+ measuringDevice: "Torque wrench TW-200",
123
+ checkpoints: [
124
+ { label: "Guard fitted", kind: "check", result: "ok" },
125
+ {
126
+ label: "Hydraulic hose intact",
127
+ kind: "check",
128
+ result: "not_ok",
129
+ finding: "Hose chafed at the clamp, replace before next shift.",
130
+ photos: [photoIds[0]],
131
+ },
132
+ {
133
+ label: "Bolt torque (Nm)",
134
+ kind: "measurement",
135
+ min: 180,
136
+ max: 200,
137
+ measured: 190,
138
+ result: "ok",
139
+ },
140
+ {
141
+ label: "Bearing play (mm)",
142
+ kind: "measurement",
143
+ min: 0.05,
144
+ max: 0.2,
145
+ measured: 0.31,
146
+ result: "not_ok",
147
+ finding: "Play above tolerance, re-measure after re-seating.",
148
+ photos: [photoIds[1]],
149
+ },
150
+ ],
151
+ continuedOperation: "with_condition",
152
+ condition: "Operate at reduced load until the hose is replaced.",
153
+ },
154
+ TestUsers.admin,
155
+ tenantDb,
156
+ );
157
+ if (!res.isSuccess) {
158
+ // biome-ignore lint/suspicious/noConsole: dev-seed surfaces failures in the runner log
159
+ console.error("[seed] styleguide inspection failed:", res.error);
160
+ }
161
+ };
@@ -22,7 +22,7 @@ import { aiTextDemoFeature } from "../features/widgets/ai-text-demo";
22
22
  import { widgetsFeature } from "../features/widgets/feature";
23
23
  import { workboardFeature } from "../features/workboard/feature";
24
24
  import { seedWorkboardTasks } from "../features/workboard/seed";
25
- import { seedStyleguideItems } from "./seed";
25
+ import { seedStyleguideInspection, seedStyleguideItems } from "./seed";
26
26
 
27
27
  const configResolver = createConfigResolver();
28
28
 
@@ -46,7 +46,7 @@ await runDevApp({
46
46
  clientEntry: "./src/app/client.tsx",
47
47
  htmlPath: "./public/index.html",
48
48
  watchDirs: ["./src", "../../../packages/*/src"],
49
- seeds: [seedStyleguideItems, seedAiConnections, seedWorkboardTasks],
49
+ seeds: [seedStyleguideItems, seedStyleguideInspection, seedAiConnections, seedWorkboardTasks],
50
50
  extraContext: ({ registry }) => ({
51
51
  configResolver,
52
52
  configAccessorFactory: createConfigAccessorFactory(registry, configResolver),
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  createBooleanField,
8
8
  createDateField,
9
+ createEmbeddedListField,
9
10
  createEntity,
10
11
  createImageField,
11
12
  createLocatedTimestampField,
@@ -70,6 +71,65 @@ export const deliveryEntity = createEntity({
70
71
  },
71
72
  });
72
73
 
74
+ // Inspection — the inspection-checklist recipe's protocol (sub-fields, derived result,
75
+ // conditions), rendered as the phone checklist in the auto-form.
76
+ const isMeasurement = { field: "kind", eq: "measurement" } as const;
77
+ const isNotOk = { field: "result", eq: "not_ok" } as const;
78
+
79
+ export const inspectionEntity = createEntity({
80
+ table: "read_examples_inspection",
81
+ fields: {
82
+ measuringDevice: createTextField({ personal: false, reason: "is_business_data" }),
83
+ checkpoints: createEmbeddedListField(
84
+ {
85
+ label: { type: "text", required: true },
86
+ kind: { type: "select", options: ["check", "measurement"], required: true },
87
+ min: { type: "number", visibleWhen: isMeasurement },
88
+ max: { type: "number", visibleWhen: isMeasurement },
89
+ measured: { type: "number", visibleWhen: isMeasurement },
90
+ result: {
91
+ type: "select",
92
+ options: ["ok", "not_ok"],
93
+ display: "radio",
94
+ optionTones: { ok: "ok", not_ok: "bad" },
95
+ },
96
+ finding: { type: "text", visibleWhen: isNotOk, requiredWhen: isNotOk },
97
+ photos: {
98
+ type: "images",
99
+ capture: "environment",
100
+ maxCount: 5,
101
+ visibleWhen: isNotOk,
102
+ requiredWhen: isNotOk,
103
+ },
104
+ },
105
+ {
106
+ personal: false,
107
+ reason: "is_business_data",
108
+ derived: {
109
+ result: {
110
+ op: "inRange",
111
+ from: ["measured", "min", "max"],
112
+ inside: "ok",
113
+ outside: "not_ok",
114
+ when: isMeasurement,
115
+ },
116
+ },
117
+ },
118
+ ),
119
+ continuedOperation: createSelectField({
120
+ options: ["yes", "with_condition", "no"],
121
+ display: "radio",
122
+ optionTones: { yes: "ok", with_condition: "warn", no: "bad" },
123
+ }),
124
+ condition: createTextField({
125
+ personal: false,
126
+ reason: "is_business_data",
127
+ visibleWhen: { field: "continuedOperation", eq: "with_condition" },
128
+ requiredWhen: { field: "continuedOperation", eq: "with_condition" },
129
+ }),
130
+ },
131
+ });
132
+
73
133
  const openReason =
74
134
  "demo app: any signed-in user edits the shipping, profile and delivery example " +
75
135
  "forms; there is no per-user ownership in this sample";
@@ -194,6 +254,35 @@ export const examplesFeature = defineFeature("examples", (r) => {
194
254
  },
195
255
  });
196
256
 
257
+ r.crud("inspection", inspectionEntity, editFormOnly);
258
+
259
+ r.screen({
260
+ id: "inspection-edit",
261
+ type: "entityEdit",
262
+ entity: "inspection",
263
+ submitLabel: "examples:inspection:submit",
264
+ layout: {
265
+ width: "full",
266
+ sections: [
267
+ {
268
+ columns: 1,
269
+ fields: [
270
+ "measuringDevice",
271
+ {
272
+ field: "checkpoints",
273
+ embeddedList: {
274
+ readOnlyCells: ["label", "kind", "min", "max"],
275
+ cardHiddenCells: ["kind"],
276
+ fixedRows: true,
277
+ },
278
+ },
279
+ ],
280
+ },
281
+ { columns: 1, fields: ["continuedOperation", "condition"] },
282
+ ],
283
+ },
284
+ });
285
+
197
286
  r.nav({ id: "examples", label: "examples:nav.examples", icon: "folder", order: 30 });
198
287
  r.nav({
199
288
  id: "shipping",
@@ -219,4 +308,12 @@ export const examplesFeature = defineFeature("examples", (r) => {
219
308
  icon: "calendar",
220
309
  order: 30,
221
310
  });
311
+ r.nav({
312
+ id: "inspection",
313
+ label: "examples:nav.inspection",
314
+ parent: "examples:nav:examples",
315
+ screen: "examples:screen:inspection-edit",
316
+ icon: "file",
317
+ order: 40,
318
+ });
222
319
  });
@@ -69,4 +69,51 @@ export const EXAMPLES_I18N: Readonly<Record<string, LocalizedString>> = {
69
69
  en: "Booked at (UTC instant)",
70
70
  },
71
71
  "examples:entity:delivery:field:homeZone": { de: "Heimatzone Kurier", en: "Courier home zone" },
72
+ "examples:nav.inspection": { de: "Prüfprotokoll", en: "Inspection checklist" },
73
+ "screen:inspection-edit.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
74
+ "screen:inspection-edit.create.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
75
+ "screen:inspection-edit.create.subtitle": {
76
+ de: "Prüfpunkte abhaken oder messen, bei Abweichung Befund und Fotos erfassen.",
77
+ en: "Check or measure each point, record a finding and photos on deviation.",
78
+ },
79
+ "screen:inspection-edit.edit.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
80
+ "screen:inspection-edit.edit.subtitle": {
81
+ de: "Prüfpunkte abhaken oder messen, bei Abweichung Befund und Fotos erfassen.",
82
+ en: "Check or measure each point, record a finding and photos on deviation.",
83
+ },
84
+ "examples:inspection:submit": { de: "Protokoll speichern", en: "Save protocol" },
85
+ "examples:entity:inspection:field:measuringDevice": { de: "Messgerät", en: "Measuring device" },
86
+ "examples:entity:inspection:field:checkpoints": { de: "Prüfpunkte", en: "Checkpoints" },
87
+ "examples:entity:inspection:field:checkpoints:cell:label": { de: "Prüfpunkt", en: "Checkpoint" },
88
+ "examples:entity:inspection:field:checkpoints:cell:kind": { de: "Art", en: "Kind" },
89
+ "examples:entity:inspection:field:checkpoints:cell:min": { de: "Min", en: "Min" },
90
+ "examples:entity:inspection:field:checkpoints:cell:max": { de: "Max", en: "Max" },
91
+ "examples:entity:inspection:field:checkpoints:cell:measured": { de: "Messwert", en: "Measured" },
92
+ "examples:entity:inspection:field:checkpoints:cell:result": { de: "Ergebnis", en: "Result" },
93
+ "examples:entity:inspection:field:checkpoints:cell:finding": { de: "Befund", en: "Finding" },
94
+ "examples:entity:inspection:field:checkpoints:cell:photos": { de: "Fotos", en: "Photos" },
95
+ "examples:entity:inspection:field:continuedOperation": {
96
+ de: "Weiterbetrieb",
97
+ en: "Continued operation",
98
+ },
99
+ "examples:entity:inspection:field:condition": { de: "Auflage", en: "Condition" },
100
+ "examples:entity:inspection:field:checkpoints:cell:kind:option:check": {
101
+ de: "Sichtprüfung",
102
+ en: "Check",
103
+ },
104
+ "examples:entity:inspection:field:checkpoints:cell:kind:option:measurement": {
105
+ de: "Messung",
106
+ en: "Measurement",
107
+ },
108
+ "examples:entity:inspection:field:checkpoints:cell:result:option:ok": { de: "i.O.", en: "OK" },
109
+ "examples:entity:inspection:field:checkpoints:cell:result:option:not_ok": {
110
+ de: "n.i.O.",
111
+ en: "Not OK",
112
+ },
113
+ "examples:entity:inspection:field:continuedOperation:option:yes": { de: "Ja", en: "Yes" },
114
+ "examples:entity:inspection:field:continuedOperation:option:with_condition": {
115
+ de: "Mit Auflage",
116
+ en: "With condition",
117
+ },
118
+ "examples:entity:inspection:field:continuedOperation:option:no": { de: "Nein", en: "No" },
72
119
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
3
- "featureCount": 89,
3
+ "featureCount": 90,
4
4
  "features": [
5
5
  {
6
6
  "name": "account-security",
@@ -914,6 +914,35 @@
914
914
  "recommended": false
915
915
  }
916
916
  },
917
+ {
918
+ "name": "documents",
919
+ "description": "Filed documents with title, category, issue and expiry date and one file, shown on the records they are linked to. A document is visible only through a linked record the viewer can read, and only while the record that owns its file is readable too, so linking never widens access to the file. Documents arrive through capture rules (a file saved into a configured field) or through the system-only import for migrations, which keeps the caller's id, refuses an id another record already uses, and stamps createdAt with the import time (issuedAt carries the business date; the event store does not backdate); users link, unlink, edit and delete them. Read a record's documents with for-record, count expired and expiring ones with expiry-overview, list them with an expiry state and an expiring filter with overview (the documents list screen), and add the documents tab to a record screen with documentsTab().",
920
+ "toggleableDefault": null,
921
+ "adoptsBundledLists": true,
922
+ "requires": [
923
+ "files"
924
+ ],
925
+ "optionalRequires": [],
926
+ "configReads": [],
927
+ "exposesApis": [],
928
+ "usesApis": [],
929
+ "extensionsUsed": [],
930
+ "configKeys": [],
931
+ "secrets": [],
932
+ "writeHandlers": [
933
+ "documents:write:filed-document:create",
934
+ "documents:write:filed-document:delete",
935
+ "documents:write:filed-document:update",
936
+ "documents:write:import",
937
+ "documents:write:link",
938
+ "documents:write:unlink"
939
+ ],
940
+ "uiHints": {
941
+ "displayLabel": "Documents",
942
+ "category": "data",
943
+ "recommended": false
944
+ }
945
+ },
917
946
  {
918
947
  "name": "feature-toggles",
919
948
  "description": "Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: \"shared\"` syncs the snapshot across instances whenever a `toggle-set` event is appended.",
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": 1,
3
+ "tables": ["read_filed_document_links", "read_filed_documents"]
4
+ }