@cosmicdrift/kumiko-renderer 0.398.0 → 0.400.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/dist/app/entity-edit-prefill.js +7 -1
- package/dist/app/entity-list-facet-specs.js +2 -0
- package/dist/app/entity-list-screen.js +3 -3
- package/dist/app/form-schema.js +42 -17
- package/dist/app/list-facets.d.ts +7 -4
- package/dist/app/list-facets.js +23 -6
- package/dist/app/projection-detail-body.js +7 -0
- package/dist/app/projection-detail-header-model.d.ts +1 -1
- package/dist/app/projection-detail-header-model.js +2 -2
- package/dist/app/projection-detail-header-region.d.ts +1 -0
- package/dist/app/projection-detail-header-region.js +1 -1
- package/dist/app/projection-detail-header.js +4 -2
- package/dist/app/projection-detail-navigation.d.ts +1 -0
- package/dist/app/projection-detail-navigation.js +9 -1
- package/dist/app/projection-list-body.js +1 -1
- package/dist/app/reference-facet-bridge.d.ts +14 -2
- package/dist/app/reference-facet-bridge.js +37 -18
- package/dist/app/use-projection-list-query.d.ts +2 -2
- package/dist/app/use-projection-list-query.js +2 -1
- package/dist/app/use-related-list-tab-counts.d.ts +5 -0
- package/dist/app/use-related-list-tab-counts.js +68 -0
- package/dist/components/embedded-list-field.js +69 -91
- package/dist/components/embedded-list-paste.d.ts +2 -0
- package/dist/components/embedded-list-paste.js +73 -0
- package/dist/components/related-list-section.js +7 -11
- package/dist/components/render-edit-fields-section.js +1 -1
- package/dist/components/render-edit-helpers.js +2 -2
- package/dist/components/render-field-choice-inputs.js +1 -1
- package/dist/data-table-facet-types.d.ts +5 -0
- package/dist/embedded-list-types.d.ts +117 -0
- package/dist/embedded-list-types.js +1 -0
- package/dist/headless/form/form-controller.js +9 -1
- package/dist/headless/index.d.ts +1 -1
- package/dist/headless/index.js +1 -1
- package/dist/headless/view-model/edit.js +40 -50
- package/dist/headless/view-model/embedded-list-cells.d.ts +12 -0
- package/dist/headless/view-model/embedded-list-cells.js +49 -0
- package/dist/headless/view-model/embedded-list.d.ts +1 -1
- package/dist/headless/view-model/embedded-list.js +1 -1
- package/dist/headless/view-model/index.d.ts +1 -1
- package/dist/headless/view-model/index.js +1 -1
- package/dist/headless/view-model/list.js +2 -3
- package/dist/headless/view-model/types.d.ts +23 -6
- package/dist/hooks/related-list-parent-payload.d.ts +2 -0
- package/dist/hooks/related-list-parent-payload.js +7 -0
- package/dist/i18n-defaults.js +11 -0
- package/dist/primitives.d.ts +4 -88
- package/dist/web/layout/notification-bell.d.ts +5 -0
- package/dist/web/layout/notification-bell.js +39 -0
- package/dist/web/layout/shell-header.js +4 -1
- package/dist/web/layout/use-in-app-inbox.d.ts +15 -0
- package/dist/web/layout/use-in-app-inbox.js +61 -0
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/data-table-cell.d.ts +2 -0
- package/dist/web/primitives/data-table-cell.js +1 -1
- package/dist/web/primitives/embedded-file-cell.d.ts +11 -0
- package/dist/web/primitives/embedded-file-cell.js +50 -0
- package/dist/web/primitives/embedded-list-cards.d.ts +38 -0
- package/dist/web/primitives/embedded-list-cards.js +71 -0
- package/dist/web/primitives/embedded-list-cell.d.ts +41 -0
- package/dist/web/primitives/embedded-list-cell.js +132 -0
- package/dist/web/primitives/embedded-list-input.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +29 -142
- package/dist/web/primitives/facet-filter.js +4 -0
- package/dist/web/primitives/facet-search-filter.d.ts +9 -0
- package/dist/web/primitives/facet-search-filter.js +31 -0
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +2 -1
- package/dist/web/primitives/input-choice-kinds.js +1 -1
- package/dist/web/primitives/input-optional-props.d.ts +2 -1
- package/dist/web/primitives/input-optional-props.js +2 -1
- package/dist/web/primitives/locale-number.d.ts +3 -0
- package/dist/web/primitives/locale-number.js +8 -0
- package/dist/web/primitives/number-input.d.ts +2 -1
- package/dist/web/primitives/number-input.js +4 -11
- package/dist/web/primitives/segmented-select.d.ts +6 -1
- package/dist/web/primitives/segmented-select.js +8 -4
- package/dist/web/sse/live-events.js +14 -3
- package/dist/web/widgets/status-badge.d.ts +1 -0
- package/dist/web/widgets/status-badge.js +1 -1
- package/package.json +5 -5
- package/src/changes.json +18 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldsEditSection, isFormDerivationInputMode, isUnusedFallbackOperand, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
-
import {
|
|
1
|
+
import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldRequired, isFieldsEditSection, isFieldVisible, isFormDerivationInputMode, isUnusedFallbackOperand, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { buildEmbeddedListCell } from "./embedded-list-cells.js";
|
|
3
|
+
import { buildOptionLabels, fieldLabelKey, fieldOptionLabelKey } from "./list.js";
|
|
3
4
|
// Resolves a writeForm section's own fieldDefs/fields through the same
|
|
4
5
|
// per-field pipeline as a plain fields section, by reusing the pipeline
|
|
5
6
|
// itself rather than duplicating it: wrap fieldDefs/fields as a throwaway
|
|
@@ -161,15 +162,22 @@ function selectableOptions(fieldDef, fieldName, values) {
|
|
|
161
162
|
const current = values[fieldName];
|
|
162
163
|
return fieldDef.options.filter((option) => available.includes(option) || option === current);
|
|
163
164
|
}
|
|
165
|
+
function deriveSelectDisplay(fieldDef) {
|
|
166
|
+
const declaredDisplay = fieldDef.type === "multiSelect" || fieldDef.type === "select" ? fieldDef.display : undefined;
|
|
167
|
+
// The renderer's heuristic counts options, so a filtered list would switch widgets with the discriminator.
|
|
168
|
+
const display = declaredDisplay ??
|
|
169
|
+
(fieldDef.type === "select" && fieldDef.conditionalOptions ? "dropdown" : undefined);
|
|
170
|
+
return {
|
|
171
|
+
...(display !== undefined && { display }),
|
|
172
|
+
...(fieldDef.type === "select" &&
|
|
173
|
+
fieldDef.optionTones !== undefined && { optionTones: fieldDef.optionTones }),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
164
176
|
function deriveSelectFieldHints(fieldDef, translate, featureName, entityName, fieldName, values) {
|
|
165
177
|
const options = selectableOptions(fieldDef, fieldName, values);
|
|
166
178
|
const optionLabels = options !== undefined
|
|
167
179
|
? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, entityName, fieldName, value), options)
|
|
168
180
|
: undefined;
|
|
169
|
-
const declaredDisplay = fieldDef.type === "multiSelect" || fieldDef.type === "select" ? fieldDef.display : undefined;
|
|
170
|
-
// The renderer's heuristic counts options, so a filtered list would switch widgets with the discriminator.
|
|
171
|
-
const display = declaredDisplay ??
|
|
172
|
-
(fieldDef.type === "select" && fieldDef.conditionalOptions ? "dropdown" : undefined);
|
|
173
181
|
const columns = fieldDef.type === "multiSelect" ? fieldDef.columns : undefined;
|
|
174
182
|
const maxRows = fieldDef.type === "multiSelect" ? fieldDef.maxRows : undefined;
|
|
175
183
|
return {
|
|
@@ -177,7 +185,7 @@ function deriveSelectFieldHints(fieldDef, translate, featureName, entityName, fi
|
|
|
177
185
|
...deriveSelectOptionsAvailabilityQuery(fieldDef),
|
|
178
186
|
...(options !== undefined && { options }),
|
|
179
187
|
...(optionLabels !== undefined && { optionLabels }),
|
|
180
|
-
...(
|
|
188
|
+
...deriveSelectDisplay(fieldDef),
|
|
181
189
|
...(columns !== undefined && { columns }),
|
|
182
190
|
...(maxRows !== undefined && { maxRows }),
|
|
183
191
|
};
|
|
@@ -195,10 +203,6 @@ function deriveTextFieldHints(fieldDef) {
|
|
|
195
203
|
...("sensitive" in fieldDef && fieldDef.sensitive === true && { sensitive: true }),
|
|
196
204
|
};
|
|
197
205
|
}
|
|
198
|
-
// files/images field defs carry no `required` property.
|
|
199
|
-
function isRequiredByEntity(fieldDef) {
|
|
200
|
-
return "required" in fieldDef && fieldDef.required === true;
|
|
201
|
-
}
|
|
202
206
|
function deriveDateFieldHints(fieldDef) {
|
|
203
207
|
const wallClock = fieldDef.type === "timestamp" && fieldDef.locatedBy !== undefined ? true : undefined;
|
|
204
208
|
const dateBounds = fieldDef.type === "date" ||
|
|
@@ -287,48 +291,16 @@ function deriveFileFieldHints(fieldDef, entityName, fieldName, entityId, actionF
|
|
|
287
291
|
...deriveImageHints(fieldDef),
|
|
288
292
|
};
|
|
289
293
|
}
|
|
290
|
-
function deriveEmbeddedCellSelectHints(subField, translate, featureName, entityName, fieldName, subFieldName) {
|
|
291
|
-
if (subField.type !== "select")
|
|
292
|
-
return {};
|
|
293
|
-
const options = subField.options ?? [];
|
|
294
|
-
return {
|
|
295
|
-
options,
|
|
296
|
-
optionLabels: buildOptionLabels(translate, (value) => embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value), options),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
function deriveEmbeddedCellReferenceHints(subField, featureName) {
|
|
300
|
-
if (subField.type !== "reference")
|
|
301
|
-
return {};
|
|
302
|
-
const target = subField.entity !== undefined ? parseRefTarget(subField.entity, featureName) : undefined;
|
|
303
|
-
return {
|
|
304
|
-
...(target !== undefined && { refEntity: target.entityName, refFeature: target.featureName }),
|
|
305
|
-
...(subField.labelField !== undefined && { refLabelField: subField.labelField }),
|
|
306
|
-
...(subField.optionsQuery !== undefined && { refOptionsQuery: subField.optionsQuery }),
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
function buildEmbeddedListCell(subFieldName, subField, translate, featureName, entityName, fieldName, listSpec) {
|
|
310
|
-
return {
|
|
311
|
-
field: subFieldName,
|
|
312
|
-
label: translate(embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName)),
|
|
313
|
-
type: subField.type,
|
|
314
|
-
required: subField.required === true,
|
|
315
|
-
...deriveEmbeddedCellSelectHints(subField, translate, featureName, entityName, fieldName, subFieldName),
|
|
316
|
-
...deriveEmbeddedCellReferenceHints(subField, featureName),
|
|
317
|
-
...(subField.type === "decimal" && subField.scale !== undefined && { scale: subField.scale }),
|
|
318
|
-
...(subField.type === "select" &&
|
|
319
|
-
subField.display !== undefined && { display: subField.display }),
|
|
320
|
-
...(listSpec?.readOnlyCells?.includes(subFieldName) === true && { readOnly: true }),
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
294
|
// Embedded-LIST field (`multiple: true`) — per-cell metadata for a renderer
|
|
324
295
|
// to draw one row per array item (invoice-positions-style table). A plain
|
|
325
296
|
// (non-list) embedded field emits none of this; the renderer tells the two
|
|
326
297
|
// apart by whether embeddedListCells is set, not by `type` (which stays
|
|
327
298
|
// "embedded" either way).
|
|
328
|
-
function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency, listSpec) {
|
|
299
|
+
function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency, listSpec, entityId) {
|
|
329
300
|
const embeddedListDef = fieldDef.type === "embedded" && fieldDef.multiple === true ? fieldDef : undefined;
|
|
301
|
+
const cellContext = { translate, featureName, entityName, fieldName, listSpec };
|
|
330
302
|
const embeddedListCells = embeddedListDef !== undefined
|
|
331
|
-
? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => buildEmbeddedListCell(subFieldName, subField,
|
|
303
|
+
? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => buildEmbeddedListCell(subFieldName, subField, cellContext))
|
|
332
304
|
: undefined;
|
|
333
305
|
return {
|
|
334
306
|
...(embeddedListCells !== undefined && { embeddedListCells }),
|
|
@@ -349,6 +321,14 @@ function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, f
|
|
|
349
321
|
...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
|
|
350
322
|
...(embeddedListDef !== undefined &&
|
|
351
323
|
listSpec?.fixedRows === true && { embeddedListFixedRows: true }),
|
|
324
|
+
...(embeddedListCells?.some((cell) => cell.type === "images" || cell.type === "files") ===
|
|
325
|
+
true && {
|
|
326
|
+
embeddedListUploadTarget: {
|
|
327
|
+
entityType: entityName,
|
|
328
|
+
fieldName,
|
|
329
|
+
...(entityId !== undefined && { entityId }),
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
352
332
|
};
|
|
353
333
|
}
|
|
354
334
|
function buildExtensionSectionViewModel(sectionSpec, translate) {
|
|
@@ -378,6 +358,17 @@ function isLockedByDerivation(derivation, values, field) {
|
|
|
378
358
|
!isFormDerivationInputMode(derivation, values, field);
|
|
379
359
|
return isDerivedAndLocked || isUnusedFallbackOperand(derivation, values, field);
|
|
380
360
|
}
|
|
361
|
+
// A screen-spec condition overrides the entity's own visibleWhen / required / requiredWhen.
|
|
362
|
+
function isEditFieldVisible(normalized, fieldDef, values) {
|
|
363
|
+
return normalized.visible !== undefined
|
|
364
|
+
? evalFieldCondition(normalized.visible, values)
|
|
365
|
+
: isFieldVisible(fieldDef, values);
|
|
366
|
+
}
|
|
367
|
+
function isEditFieldRequired(normalized, fieldDef, values) {
|
|
368
|
+
return normalized.required !== undefined
|
|
369
|
+
? evalFieldCondition(normalized.required, values)
|
|
370
|
+
: isFieldRequired(fieldDef, values);
|
|
371
|
+
}
|
|
381
372
|
export function computeEditViewModel(input) {
|
|
382
373
|
const { screen, entity, values, translate, featureName, mode, entityId, derivation, actionFormScreenQn, } = input;
|
|
383
374
|
const excludedFields = new Set(mode === undefined ? [] : screen.writeExcludedFields?.[mode]);
|
|
@@ -419,7 +410,7 @@ export function computeEditViewModel(input) {
|
|
|
419
410
|
const effectiveType = declaredRefTarget !== undefined ? "reference" : fieldDef.type;
|
|
420
411
|
const label = translate(screen.fieldLabels?.[normalized.field] ??
|
|
421
412
|
fieldLabelKey(featureName, screen.entity, normalized.field));
|
|
422
|
-
const visible =
|
|
413
|
+
const visible = isEditFieldVisible(normalized, fieldDef, values);
|
|
423
414
|
// `readOnly` (camelCase) is the name on both sides: EditFieldSpec
|
|
424
415
|
// in the engine, and the view-model emitted here. One convention
|
|
425
416
|
// through the stack beats translating at the boundary.
|
|
@@ -431,8 +422,7 @@ export function computeEditViewModel(input) {
|
|
|
431
422
|
// field that's required at the entity-level but marked required:
|
|
432
423
|
// false on the screen (e.g. a soft-onboarding wizard that
|
|
433
424
|
// collects less up-front) respects the screen override.
|
|
434
|
-
const
|
|
435
|
-
const required = !lockedByDerivation && evalCondition(normalized.required, entityRequired, values);
|
|
425
|
+
const required = !lockedByDerivation && isEditFieldRequired(normalized, fieldDef, values);
|
|
436
426
|
// ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
|
|
437
427
|
// framework/src/engine/field-helpers.ts — headless has no dependency
|
|
438
428
|
// on that module, so the literal is duplicated here instead of
|
|
@@ -444,7 +434,7 @@ export function computeEditViewModel(input) {
|
|
|
444
434
|
const numericHints = deriveNumericFieldHints(fieldDef, resolvedCurrency);
|
|
445
435
|
const referenceHints = deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, featureName);
|
|
446
436
|
const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field, entityId, actionFormScreenQn);
|
|
447
|
-
const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency, normalized.embeddedList);
|
|
437
|
+
const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency, normalized.embeddedList, entityId);
|
|
448
438
|
const view = {
|
|
449
439
|
field: normalized.field,
|
|
450
440
|
label,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EmbeddedListEditSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { EmbeddedSubFieldDef } from "@cosmicdrift/kumiko-types/fields";
|
|
3
|
+
import type { EmbeddedListCellViewModel, Translate } from "./types.ts";
|
|
4
|
+
type CellContext = {
|
|
5
|
+
readonly translate: Translate;
|
|
6
|
+
readonly featureName: string;
|
|
7
|
+
readonly entityName: string;
|
|
8
|
+
readonly fieldName: string;
|
|
9
|
+
readonly listSpec: EmbeddedListEditSpec | undefined;
|
|
10
|
+
};
|
|
11
|
+
export declare function buildEmbeddedListCell(subFieldName: string, subField: EmbeddedSubFieldDef, ctx: CellContext): EmbeddedListCellViewModel;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { buildOptionLabels, embeddedCellLabelKey, embeddedCellOptionLabelKey } from "./list.js";
|
|
3
|
+
function selectHints(subField, subFieldName, ctx) {
|
|
4
|
+
if (subField.type !== "select")
|
|
5
|
+
return {};
|
|
6
|
+
return {
|
|
7
|
+
options: subField.options,
|
|
8
|
+
optionLabels: buildOptionLabels(ctx.translate, (value) => embeddedCellOptionLabelKey(ctx.featureName, ctx.entityName, ctx.fieldName, subFieldName, value), subField.options),
|
|
9
|
+
...(subField.display !== undefined && { display: subField.display }),
|
|
10
|
+
...(subField.optionTones !== undefined && { optionTones: subField.optionTones }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function referenceHints(subField, featureName) {
|
|
14
|
+
if (subField.type !== "reference")
|
|
15
|
+
return {};
|
|
16
|
+
const target = parseRefTarget(subField.entity, featureName);
|
|
17
|
+
return {
|
|
18
|
+
refEntity: target.entityName,
|
|
19
|
+
refFeature: target.featureName,
|
|
20
|
+
...(subField.labelField !== undefined && { refLabelField: subField.labelField }),
|
|
21
|
+
...(subField.optionsQuery !== undefined && { refOptionsQuery: subField.optionsQuery }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function fileHints(subField) {
|
|
25
|
+
if (subField.type !== "images" && subField.type !== "files")
|
|
26
|
+
return {};
|
|
27
|
+
return {
|
|
28
|
+
...(subField.maxCount !== undefined && { maxCount: subField.maxCount }),
|
|
29
|
+
...(subField.accept !== undefined && { accept: subField.accept }),
|
|
30
|
+
...(subField.type === "images" &&
|
|
31
|
+
subField.capture !== undefined && { capture: subField.capture }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function buildEmbeddedListCell(subFieldName, subField, ctx) {
|
|
35
|
+
return {
|
|
36
|
+
field: subFieldName,
|
|
37
|
+
label: ctx.translate(embeddedCellLabelKey(ctx.featureName, ctx.entityName, ctx.fieldName, subFieldName)),
|
|
38
|
+
type: subField.type,
|
|
39
|
+
required: subField.required === true,
|
|
40
|
+
...(subField.visibleWhen !== undefined && { visibleWhen: subField.visibleWhen }),
|
|
41
|
+
...(subField.requiredWhen !== undefined && { requiredWhen: subField.requiredWhen }),
|
|
42
|
+
...selectHints(subField, subFieldName, ctx),
|
|
43
|
+
...referenceHints(subField, ctx.featureName),
|
|
44
|
+
...(subField.type === "decimal" && subField.scale !== undefined && { scale: subField.scale }),
|
|
45
|
+
...fileHints(subField),
|
|
46
|
+
...(ctx.listSpec?.readOnlyCells?.includes(subFieldName) === true && { readOnly: true }),
|
|
47
|
+
...(ctx.listSpec?.cardHiddenCells?.includes(subFieldName) === true && { hiddenInCard: true }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { EmbeddedDerivedCellDef } from "@cosmicdrift/kumiko-framework/ui-ty
|
|
|
2
2
|
import type { FieldIssue } from "../dispatcher/index.ts";
|
|
3
3
|
export type EmbeddedDerivedOp = EmbeddedDerivedCellDef["op"];
|
|
4
4
|
export type { DerivedCellRoundingTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
-
export { computeDerivedCellValue, roundDerivedCellValue, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
6
|
/** Sums a numeric/money/decimal column across all rows. Non-numeric or
|
|
7
7
|
* missing values count as 0. Money columns are minor-unit integers —
|
|
8
8
|
* the sum stays in minor units, never rounds through a major-unit float. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Canonical implementation moved to packages/framework/src/engine/embedded-derived.ts
|
|
2
2
|
// (the write-schema preprocess needs it server-side too).
|
|
3
|
-
export { computeDerivedCellValue, roundDerivedCellValue, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
4
4
|
/** Sums a numeric/money/decimal column across all rows. Non-numeric or
|
|
5
5
|
* missing values count as 0. Money columns are minor-unit integers —
|
|
6
6
|
* the sum stays in minor units, never rounds through a major-unit float. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type { ComputeEditViewModelInput, EditFormMode } from "./edit.ts";
|
|
2
2
|
export { computeEditViewModel, computeRelatedListSectionViewModel } from "./edit.ts";
|
|
3
3
|
export type { DerivedCellRoundingTarget, EmbeddedDerivedOp, EmbeddedListIssueGroups, } from "./embedded-list.ts";
|
|
4
|
-
export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./embedded-list.ts";
|
|
4
|
+
export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./embedded-list.ts";
|
|
5
5
|
export type { ComputeListViewModelInput } from "./list.ts";
|
|
6
6
|
export { buildOptionLabels, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.ts";
|
|
7
7
|
export type { CellLink, CellLinkItem, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedListCellViewModel, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeCellLink, RuntimeCellLinkItems, RuntimeRenderer, ScreenSlots, Translate, } from "./types.ts";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { computeEditViewModel, computeRelatedListSectionViewModel } from "./edit.js";
|
|
2
|
-
export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./embedded-list.js";
|
|
2
|
+
export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./embedded-list.js";
|
|
3
3
|
export { buildOptionLabels, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.js";
|
|
@@ -136,9 +136,8 @@ function derivedFieldColumn(input, column) {
|
|
|
136
136
|
field: column.field,
|
|
137
137
|
label: translatedColumnLabel(input, column),
|
|
138
138
|
type: derivedDef.valueType,
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
sortable: false,
|
|
139
|
+
// The client schema carries only `sortable` (never `sql`), so test the flag, not the SQL shape.
|
|
140
|
+
sortable: "sortable" in derivedDef && derivedDef.sortable === true,
|
|
142
141
|
...(column.renderer !== undefined && { renderer: column.renderer }),
|
|
143
142
|
...(column.hideOnNarrow === true && { hideOnNarrow: true }),
|
|
144
143
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EditFieldSpec, EditSectionSpec, FieldAffix, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import type { EditFieldSpec, EditSectionSpec, EmbeddedDerivedCellDef, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
export type RuntimeRenderer = (value: unknown, row?: Readonly<Record<string, unknown>>) => string;
|
|
3
3
|
/** Where a cell's text links to. `href` comes from NavApi.hrefFor (new-tab and
|
|
4
4
|
* copy-link work); `onPress` is the in-app navigation for a plain click. */
|
|
@@ -79,15 +79,26 @@ export type ListViewModel = {
|
|
|
79
79
|
export type EmbeddedListCellViewModel = {
|
|
80
80
|
readonly field: string;
|
|
81
81
|
readonly label: string;
|
|
82
|
-
readonly type: "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp";
|
|
82
|
+
readonly type: "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp" | "images" | "files";
|
|
83
83
|
readonly required: boolean;
|
|
84
|
+
/** Per-row state: hidden / required for a row where the condition holds. */
|
|
85
|
+
readonly visibleWhen?: Exclude<FieldCondition, boolean>;
|
|
86
|
+
readonly requiredWhen?: Exclude<FieldCondition, boolean>;
|
|
84
87
|
/** Only for `type: "select"`. */
|
|
85
88
|
readonly options?: readonly string[];
|
|
89
|
+
/** Only for `type: "select"`; tone of each option's badge or selected segment. */
|
|
90
|
+
readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
|
|
91
|
+
/** Only for `type: "images" | "files"`. */
|
|
92
|
+
readonly capture?: "user" | "environment";
|
|
93
|
+
readonly maxCount?: number;
|
|
94
|
+
readonly accept?: readonly string[];
|
|
86
95
|
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
87
96
|
/** Only for `type: "select"`; `"radio"` renders segmented buttons instead of a dropdown. */
|
|
88
97
|
readonly display?: "radio" | "dropdown";
|
|
89
98
|
/** Cell is shown but not editable (`EditFieldSpec.embeddedList.readOnlyCells`). */
|
|
90
99
|
readonly readOnly?: boolean;
|
|
100
|
+
/** Not shown on phone cards (`EditFieldSpec.embeddedList.cardHiddenCells`). */
|
|
101
|
+
readonly hiddenInCard?: boolean;
|
|
91
102
|
/** Only for `type: "reference"`. */
|
|
92
103
|
readonly refEntity?: string;
|
|
93
104
|
readonly refFeature?: string;
|
|
@@ -140,6 +151,8 @@ export type EditFieldViewModel = {
|
|
|
140
151
|
* `type: "select"` when SelectFieldDef.display is "radio" or "dropdown" —
|
|
141
152
|
* the renderer then skips its own radio-vs-dropdown heuristic. */
|
|
142
153
|
readonly display?: "checkboxes" | "dropdown" | "radio";
|
|
154
|
+
/** `type: "select"` only: tone per option, shown on the selected radio segment. */
|
|
155
|
+
readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
|
|
143
156
|
/** Set for `type: "multiSelect"` when MultiSelectFieldDef.columns is
|
|
144
157
|
* defined — column count for the checkbox grid at the widest breakpoint.
|
|
145
158
|
* Ignored unless `display` is "checkboxes". */
|
|
@@ -249,10 +262,7 @@ export type EditFieldViewModel = {
|
|
|
249
262
|
readonly embeddedListMaxItems?: number;
|
|
250
263
|
/** Sub-field name → how to compute its cell from other sub-fields of the
|
|
251
264
|
* same row. From EmbeddedFieldDef.derived. */
|
|
252
|
-
readonly embeddedListDerived?: Readonly<Record<string,
|
|
253
|
-
readonly op: "multiply" | "sum" | "subtract" | "percent";
|
|
254
|
-
readonly from: readonly string[];
|
|
255
|
-
}>>;
|
|
265
|
+
readonly embeddedListDerived?: Readonly<Record<string, EmbeddedDerivedCellDef>>;
|
|
256
266
|
/** Footer totals: a sub-field name, or a labelled sum of several. From EmbeddedFieldDef.totals. */
|
|
257
267
|
readonly embeddedListTotals?: readonly (string | {
|
|
258
268
|
readonly label: string;
|
|
@@ -261,6 +271,13 @@ export type EditFieldViewModel = {
|
|
|
261
271
|
/** No add, remove, duplicate or move; cells stay editable. From
|
|
262
272
|
* EditFieldSpec.embeddedList.fixedRows. */
|
|
263
273
|
readonly embeddedListFixedRows?: boolean;
|
|
274
|
+
/** Where photos of a row's file cells are uploaded: the entity and the top-level list field;
|
|
275
|
+
* the cell name travels as `subField`. Set when the list has a file cell. */
|
|
276
|
+
readonly embeddedListUploadTarget?: {
|
|
277
|
+
readonly entityType: string;
|
|
278
|
+
readonly fieldName: string;
|
|
279
|
+
readonly entityId?: string;
|
|
280
|
+
};
|
|
264
281
|
/** Currency for the whole embedded list's money cells and totals row —
|
|
265
282
|
* currency lives on the head aggregate (EntityDefinition.defaultCurrency),
|
|
266
283
|
* not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `parentFilter` sends the parent id as a server-side `filter` clause: the generic
|
|
2
|
+
// `<entity>:list` query understands `filter`, not an arbitrary `parentParam` key.
|
|
3
|
+
export function buildRelatedListParentPayload(section, parentId) {
|
|
4
|
+
return section.parentFilter !== undefined
|
|
5
|
+
? { filter: { field: section.parentFilter.field, op: "eq", value: parentId } }
|
|
6
|
+
: { [section.parentParam ?? "id"]: parentId };
|
|
7
|
+
}
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -50,6 +50,9 @@ export const kumikoDefaultTranslations = {
|
|
|
50
50
|
"kumiko.field.writeOnly.willRemove": "Removed on save",
|
|
51
51
|
"kumiko.field.embedded-list.actions": "Actions",
|
|
52
52
|
"kumiko.field.embedded-list.cell-label": "{column}, row {row}",
|
|
53
|
+
"kumiko.field.embedded-list.limit-range": "Limit {min} to {max}",
|
|
54
|
+
"kumiko.field.embedded-list.limit-min": "At least {min}",
|
|
55
|
+
"kumiko.field.embedded-list.limit-max": "At most {max}",
|
|
53
56
|
"kumiko.field.file-list.add": "Add file",
|
|
54
57
|
"kumiko.field.file-list.move-up": "Move file up",
|
|
55
58
|
"kumiko.field.file-list.move-down": "Move file down",
|
|
@@ -242,6 +245,14 @@ export const kumikoDefaultTranslations = {
|
|
|
242
245
|
"kumiko.nav.expand": "Expand",
|
|
243
246
|
"kumiko.nav.collapse": "Collapse",
|
|
244
247
|
"kumiko.nav.search": "Search navigation…",
|
|
248
|
+
"kumiko.notifications.label": "Notifications",
|
|
249
|
+
"kumiko.notifications.labelUnread": "Notifications, {count} unread",
|
|
250
|
+
"kumiko.notifications.title": "Notifications",
|
|
251
|
+
"kumiko.notifications.empty": "No notifications yet.",
|
|
252
|
+
"kumiko.notifications.markAllRead": "Mark all as read",
|
|
253
|
+
"kumiko.notifications.loadMore": "Load more",
|
|
254
|
+
"kumiko.notifications.unread": "Unread",
|
|
255
|
+
"kumiko.notifications.loadFailed": "Notifications could not be loaded.",
|
|
245
256
|
"kumiko.nav.language": "Language",
|
|
246
257
|
"kumiko.workspace.switch": "Switch workspace",
|
|
247
258
|
// Workspace-Switcher fallback label when activeId points at no visible workspace.
|
package/dist/primitives.d.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { type ComponentType, type FormEvent, type KeyboardEvent, type ReactNode,
|
|
|
4
4
|
import type { DataTableBulk } from "./data-table-bulk-types.ts";
|
|
5
5
|
import type { DataTableDateRangeFacet, DataTableFacet } from "./data-table-facet-types.ts";
|
|
6
6
|
import type { DataTableTableBlock } from "./data-table-view-types.ts";
|
|
7
|
+
import type { EmbeddedListInputProps } from "./embedded-list-types.ts";
|
|
7
8
|
import type { FieldIssue, ListColumnViewModel, ListRowViewModel, RuntimeRenderer } from "./headless/index.ts";
|
|
8
9
|
import type { MetricBandProps, MetricProps } from "./metric-types.ts";
|
|
10
|
+
export type { EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, } from "./embedded-list-types.ts";
|
|
9
11
|
export type { RuntimeRenderer };
|
|
10
12
|
/** Escape hatch for `data-*` test hooks (e.g. an E2E selector) that don't
|
|
11
13
|
* warrant a dedicated typed prop. Web merges these onto the rendered
|
|
@@ -460,6 +462,8 @@ export type InputProps = {
|
|
|
460
462
|
* decides; the web impl keeps its short-option-set heuristic. Custom
|
|
461
463
|
* impls may ignore it — it is a request, not a contract. */
|
|
462
464
|
readonly display?: "radio" | "dropdown";
|
|
465
|
+
/** Tone per option value; the selected segment of a radio presentation takes its colors. */
|
|
466
|
+
readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
|
|
463
467
|
} | ({
|
|
464
468
|
readonly kind: "combobox";
|
|
465
469
|
readonly id: string;
|
|
@@ -797,94 +801,6 @@ export type DataTableProps = {
|
|
|
797
801
|
readonly renderExpandedRow?: (row: ListRowViewModel) => ReactNode;
|
|
798
802
|
} & DataTableTableBlock;
|
|
799
803
|
export type DataTableTableBlockProps = Pick<DataTableProps, keyof DataTableTableBlock>;
|
|
800
|
-
/** Cell type for one column of an embedded-list field. Mirrors
|
|
801
|
-
* EmbeddedListCellViewModel["type"] from `@cosmicdrift/kumiko-renderer/headless`. */
|
|
802
|
-
export type EmbeddedListCellType = "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp";
|
|
803
|
-
/** One column of an embedded-list table (one entry per key of the source
|
|
804
|
-
* EmbeddedFieldDef's `schema`). */
|
|
805
|
-
export type EmbeddedListColumn = {
|
|
806
|
-
readonly field: string;
|
|
807
|
-
readonly label: string;
|
|
808
|
-
readonly type: EmbeddedListCellType;
|
|
809
|
-
readonly required: boolean;
|
|
810
|
-
/** Read-only, value supplied by the caller (already computed from
|
|
811
|
-
* sibling cells) — not directly user-editable. */
|
|
812
|
-
readonly derived: boolean;
|
|
813
|
-
/** Shown but not editable by the user; unlike `derived` it is not computed. */
|
|
814
|
-
readonly readOnly?: boolean;
|
|
815
|
-
/** Only for `type: "select"`. */
|
|
816
|
-
readonly options?: readonly string[];
|
|
817
|
-
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
818
|
-
/** Only for `type: "select"`; `"radio"` renders segmented buttons, the
|
|
819
|
-
* default is a dropdown. */
|
|
820
|
-
readonly display?: "radio" | "dropdown";
|
|
821
|
-
/** Only for `type: "reference"` — pre-fetched by the caller, ONE query
|
|
822
|
-
* per column shared across every row (not one query per cell). */
|
|
823
|
-
readonly referenceOptions?: readonly {
|
|
824
|
-
readonly value: string;
|
|
825
|
-
readonly label: string;
|
|
826
|
-
}[];
|
|
827
|
-
readonly referenceLoading?: boolean;
|
|
828
|
-
};
|
|
829
|
-
/** One entry of an embedded-list totals row (e.g. an invoice's total
|
|
830
|
-
* amount, summed across all rows by the caller). */
|
|
831
|
-
export type EmbeddedListTotal = {
|
|
832
|
-
readonly field: string;
|
|
833
|
-
readonly label: string;
|
|
834
|
-
readonly value: number;
|
|
835
|
-
/** Set for labelled multi-column totals, which have no single column to
|
|
836
|
-
* read the money type from. */
|
|
837
|
-
readonly money?: boolean;
|
|
838
|
-
};
|
|
839
|
-
/** Row-array + totals table for a `createEmbeddedListField` (invoice-
|
|
840
|
-
* positions-style) field — structurally too different from `Input`'s
|
|
841
|
-
* value/onChange union to fit there, so it's its own primitive, same as
|
|
842
|
-
* `DataTable`. Controlled: the caller (the eventual form-field wrapper)
|
|
843
|
-
* computes rows/derived values/issue groups and wires every callback;
|
|
844
|
-
* this primitive only renders and reports interaction. */
|
|
845
|
-
export type EmbeddedListInputProps = {
|
|
846
|
-
readonly id: string;
|
|
847
|
-
readonly columns: readonly EmbeddedListColumn[];
|
|
848
|
-
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
849
|
-
readonly totals?: readonly EmbeddedListTotal[];
|
|
850
|
-
/** Currency for money cells and the totals row — one value for the whole
|
|
851
|
-
* list (currency lives on the head aggregate, not the row). Web impl
|
|
852
|
-
* falls back to "EUR" when absent. */
|
|
853
|
-
readonly currency?: string;
|
|
854
|
-
/** Whole-list read-only (e.g. a released invoice) — hides row-mutation
|
|
855
|
-
* affordances and disables every cell. */
|
|
856
|
-
readonly disabled?: boolean;
|
|
857
|
-
/** Rows are fixed: no add, remove, duplicate or move; cells stay editable. */
|
|
858
|
-
readonly fixedRows?: boolean;
|
|
859
|
-
readonly minItems?: number;
|
|
860
|
-
readonly maxItems?: number;
|
|
861
|
-
/** Shown under the whole list (e.g. under the totals row). */
|
|
862
|
-
readonly listIssues?: readonly FieldIssue[];
|
|
863
|
-
/** Keyed by row index. */
|
|
864
|
-
readonly rowIssues?: Readonly<Record<number, readonly FieldIssue[]>>;
|
|
865
|
-
/** Keyed `${rowIndex}.${field}`. */
|
|
866
|
-
readonly cellIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
867
|
-
readonly onCellChange: (rowIndex: number, field: string, value: unknown) => void;
|
|
868
|
-
readonly onAddRow: () => void;
|
|
869
|
-
readonly onRemoveRow: (rowIndex: number) => void;
|
|
870
|
-
readonly onDuplicateRow: (rowIndex: number) => void;
|
|
871
|
-
readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
|
|
872
|
-
/** Tab/newline-delimited clipboard paste starting at (rowIndex,
|
|
873
|
-
* columnIndex) — parsed to a 2D string grid by the Web impl (paste
|
|
874
|
-
* events are a browser-only concept); undefined caller = no paste
|
|
875
|
-
* handling wired up. */
|
|
876
|
-
readonly onPasteCells?: (rowIndex: number, columnIndex: number, grid: readonly (readonly string[])[]) => void;
|
|
877
|
-
readonly addLabel: string;
|
|
878
|
-
/** Accessible name of the (otherwise headerless) row-actions column. */
|
|
879
|
-
readonly actionsLabel: string;
|
|
880
|
-
readonly removeLabel: string;
|
|
881
|
-
readonly duplicateLabel: string;
|
|
882
|
-
readonly moveUpLabel: string;
|
|
883
|
-
readonly moveDownLabel: string;
|
|
884
|
-
readonly emptyLabel: string;
|
|
885
|
-
readonly emptyCtaLabel: string;
|
|
886
|
-
readonly testId?: string;
|
|
887
|
-
};
|
|
888
804
|
export type { FormWidth };
|
|
889
805
|
/** One entry of a form's "on this page" navigation: `id` matches the
|
|
890
806
|
* `SectionProps.id` of the section it scrolls to. */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Bell } from "lucide-react";
|
|
3
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { lastSegment, useLocale, useNav, useTranslation } from "../../index.js";
|
|
6
|
+
import { useRelativeTimeFormatter } from "../app/relative-time.js";
|
|
7
|
+
import { cn } from "../lib/cn.js";
|
|
8
|
+
import { useInAppInbox } from "./use-in-app-inbox.js";
|
|
9
|
+
const MAX_BADGE_COUNT = 99;
|
|
10
|
+
const triggerClass = "relative inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input " +
|
|
11
|
+
"bg-background text-foreground shadow-xs transition-colors hover:bg-accent " +
|
|
12
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
13
|
+
const panelClass = "z-50 flex max-h-[28rem] w-[min(22rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-md border " +
|
|
14
|
+
"bg-popover text-popover-foreground shadow-md";
|
|
15
|
+
export function NotificationBell({ screens, }) {
|
|
16
|
+
const t = useTranslation();
|
|
17
|
+
const nav = useNav();
|
|
18
|
+
const locale = useLocale().locale();
|
|
19
|
+
const formatRelative = useRelativeTimeFormatter(locale);
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const inbox = useInAppInbox(open);
|
|
22
|
+
const badge = inbox.unreadCount > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : String(inbox.unreadCount);
|
|
23
|
+
const label = inbox.unreadCount > 0
|
|
24
|
+
? t("kumiko.notifications.labelUnread", { count: badge })
|
|
25
|
+
: t("kumiko.notifications.label");
|
|
26
|
+
const openRow = async (row) => {
|
|
27
|
+
if (!row.isRead)
|
|
28
|
+
await inbox.markRead(row.id);
|
|
29
|
+
const target = row.target;
|
|
30
|
+
if (target === null)
|
|
31
|
+
return;
|
|
32
|
+
if (!screens.some((screen) => screen.id === target.screenId))
|
|
33
|
+
return;
|
|
34
|
+
const screenId = lastSegment(target.screenId);
|
|
35
|
+
setOpen(false);
|
|
36
|
+
nav.navigate(target.entityId === undefined ? { screenId } : { screenId, entityId: target.entityId });
|
|
37
|
+
};
|
|
38
|
+
return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": label, "data-kumiko-layout": "notification-bell", className: triggerClass, children: [_jsx(Bell, { className: "size-4", "aria-hidden": "true" }), inbox.unreadCount > 0 && (_jsx("span", { "data-kumiko-layout": "notification-bell-badge", className: "absolute -right-1.5 -top-1.5 flex min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-4 text-destructive-foreground", children: badge }))] }) }), _jsx(PopoverPrimitive.Portal, { children: _jsxs(PopoverPrimitive.Content, { align: "end", sideOffset: 6, className: panelClass, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border px-3 py-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: t("kumiko.notifications.title") }), inbox.unreadCount > 0 && (_jsx("button", { type: "button", className: "text-xs text-primary hover:underline", onClick: () => void inbox.markAllRead(), children: t("kumiko.notifications.markAllRead") }))] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [inbox.failed && (_jsx("p", { role: "alert", className: "px-3 py-4 text-sm text-destructive", children: t("kumiko.notifications.loadFailed") })), !inbox.failed && !inbox.loading && inbox.rows.length === 0 && (_jsx("p", { className: "px-3 py-6 text-center text-sm text-muted-foreground", children: t("kumiko.notifications.empty") })), _jsx("ul", { children: inbox.rows.map((row) => (_jsx("li", { children: _jsxs("button", { type: "button", "data-unread": row.isRead ? undefined : "true", className: "flex w-full items-start gap-2 border-b border-border px-3 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none", onClick: () => void openRow(row), children: [_jsx("span", { "aria-hidden": "true", className: cn("mt-1.5 size-2 shrink-0 rounded-full", row.isRead ? "bg-transparent" : "bg-primary") }), !row.isRead && (_jsx("span", { className: "sr-only", children: t("kumiko.notifications.unread") })), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: cn("block truncate text-sm", !row.isRead && "font-semibold"), children: row.title }), row.body !== null && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: row.body })), _jsx("span", { className: "block text-xs text-muted-foreground", children: formatRelative(Date.parse(row.createdAt)) })] })] }) }, row.id))) }), inbox.hasMore && (_jsx("button", { type: "button", className: "w-full px-3 py-2 text-sm text-primary hover:bg-accent", onClick: () => void inbox.loadMore(), children: t("kumiko.notifications.loadMore") }))] })] }) })] }));
|
|
39
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
7
7
|
// The `data-kumiko-layout="shell-header"` marker drives `--shell-header-height`
|
|
8
8
|
// in styles.css (:has() selector) — the single source for the header height
|
|
9
9
|
// that Drawer's `belowHeader` prop also reads.
|
|
10
|
+
import { IN_APP_NOTIFICATION_FEATURE } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
10
11
|
import { MoreHorizontal } from "lucide-react";
|
|
11
12
|
import { Fragment, useEffect, useId, useMemo, useRef, useState, } from "react";
|
|
12
13
|
import { resolveNavigation } from "../../headless/index.js";
|
|
@@ -17,6 +18,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbP
|
|
|
17
18
|
import { SidebarTrigger } from "../ui/sidebar.js";
|
|
18
19
|
import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
|
|
19
20
|
import { buildNavRegistrySliceForApp } from "./nav-tree.js";
|
|
21
|
+
import { NotificationBell } from "./notification-bell.js";
|
|
20
22
|
import { usePageHeaderSlot } from "./page-header-slot.js";
|
|
21
23
|
import { activeNavLabel, resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
|
|
22
24
|
import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
|
|
@@ -35,6 +37,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
|
|
|
35
37
|
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
36
38
|
}, [appSchema, user, allowedNavQns]);
|
|
37
39
|
const allScreens = useMemo(() => appSchema.features.flatMap((f) => f.screens), [appSchema.features]);
|
|
40
|
+
const hasInAppChannel = appSchema.features.some((feature) => feature.featureName === IN_APP_NOTIFICATION_FEATURE);
|
|
38
41
|
const slot = usePageHeaderSlot();
|
|
39
42
|
const standInScreenId = slot?.breadcrumbScreenId;
|
|
40
43
|
const screenId = standInScreenId ?? nav.route?.screenId;
|
|
@@ -78,7 +81,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
|
|
|
78
81
|
e.preventDefault();
|
|
79
82
|
nav.navigate({ screenId });
|
|
80
83
|
}, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
|
|
81
|
-
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
84
|
+
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), user !== undefined && hasInAppChannel && _jsx(NotificationBell, { screens: allScreens }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
82
85
|
}
|
|
83
86
|
// A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
|
|
84
87
|
// nodes with their own popovers, and the panel stays mounted while closed so their
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type InAppInboxRow } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
|
+
export declare const IN_APP_INBOX_PAGE_SIZE = 20;
|
|
3
|
+
export type InAppInbox = {
|
|
4
|
+
readonly unreadCount: number;
|
|
5
|
+
readonly rows: readonly InAppInboxRow[];
|
|
6
|
+
readonly hasMore: boolean;
|
|
7
|
+
readonly loading: boolean;
|
|
8
|
+
readonly failed: boolean;
|
|
9
|
+
readonly loadMore: () => Promise<void>;
|
|
10
|
+
readonly markRead: (id: number) => Promise<void>;
|
|
11
|
+
readonly markAllRead: () => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
/** Headless state of the notification bell: the unread count always, the list only while `open`.
|
|
14
|
+
* The delivered signal refetches both; no polling. */
|
|
15
|
+
export declare function useInAppInbox(open: boolean): InAppInbox;
|