@conduction/nextcloud-vue 2.67.0 → 2.69.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/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +21 -35
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +17 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +40 -3
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +58 -16
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +87 -18
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +6 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +58 -48
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
- package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +8 -2
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +2 -2
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +70 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +23 -3
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
- package/dist/esm/composables/useContextMenu.js +4 -2
- package/dist/esm/composables/useContextMenu.js.map +1 -1
- package/dist/esm/l10n/en.json.js +3 -0
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +3 -0
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/mixins/pageViews.js +278 -11
- package/dist/esm/mixins/pageViews.js.map +1 -1
- package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
- package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
- package/dist/esm/utils/formatMetric.js +137 -16
- package/dist/esm/utils/formatMetric.js.map +1 -1
- package/dist/esm/utils/resolveRowActions.js +130 -0
- package/dist/esm/utils/resolveRowActions.js.map +1 -0
- package/dist/esm/utils/rowActionItem.js +104 -0
- package/dist/esm/utils/rowActionItem.js.map +1 -0
- package/dist/esm/utils/timelineEvents.js +44 -1
- package/dist/esm/utils/timelineEvents.js.map +1 -1
- package/dist/esm/utils/validateManifest.js +59 -3
- package/dist/esm/utils/validateManifest.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +1251 -232
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/l10n/en.json +3 -0
- package/l10n/nl.json +3 -0
- package/package.json +1 -1
- package/scripts/.jsdoc-baselines.json +2 -2
- package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
- package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +34 -35
- package/src/components/CnDetailPage/CnDetailPage.md +6 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +67 -5
- package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
- package/src/components/CnIndexPage/CnIndexPage.md +21 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +61 -51
- package/src/components/CnIndexPage/defaultActions.js +6 -7
- package/src/components/CnRowActions/CnRowActions.vue +36 -37
- package/src/components/CnSetupWizard/CnSetupWizard.vue +8 -2
- package/src/components/CnStatWidget/CnStatWidget.vue +2 -2
- package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +82 -3
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
- package/src/composables/useContextMenu.js +4 -2
- package/src/mixins/pageViews.js +277 -10
- package/src/schemas/app-manifest-v2.schema.json +73 -9
- package/src/store/plugins/dashboardLayouts.js +34 -0
- package/src/utils/formatMetric.js +143 -15
- package/src/utils/resolveRowActions.js +127 -0
- package/src/utils/rowActionItem.js +100 -0
- package/src/utils/timelineEvents.js +43 -0
- package/src/utils/validateManifest.js +60 -3
- package/src/utils/validateManifestV2.compiled.js +2 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
|
|
4
4
|
"title": "Conduction App Manifest v2",
|
|
5
5
|
"description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.51.0",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"required": ["$schema", "version"],
|
|
9
9
|
"allOf": [
|
|
@@ -2903,7 +2903,7 @@
|
|
|
2903
2903
|
"type": "object",
|
|
2904
2904
|
"required": ["register", "schema"],
|
|
2905
2905
|
"additionalProperties": false,
|
|
2906
|
-
"description": "One KPI entry inside a multi-entry stats-block widget (CnStatsBlockWidget `entries[]`). Carries the same source contract as a type:'stat' widget: register + schema + metric + token-resolved filter (@today / @me / @workspace.* and ?-optional clauses), plus per-entry presentation (title, variant, countLabel), an optional route deep link, and hideWhenZero.",
|
|
2906
|
+
"description": "One KPI entry inside a multi-entry stats-block widget (CnStatsBlockWidget `entries[]`). Carries the same source contract as a type:'stat' widget: register + schema + metric + token-resolved filter (@today / @me / @workspace.* and ?-optional clauses), plus per-entry presentation (title, variant, countLabel, format), an optional route deep link, and hideWhenZero. A money value names its currency with currencyField (the page object's field) or currency.",
|
|
2907
2907
|
"properties": {
|
|
2908
2908
|
"title": {
|
|
2909
2909
|
"type": "string",
|
|
@@ -2952,6 +2952,32 @@
|
|
|
2952
2952
|
"type": "boolean",
|
|
2953
2953
|
"default": false,
|
|
2954
2954
|
"description": "When true, the entry is omitted from the card when its resolved count is 0."
|
|
2955
|
+
},
|
|
2956
|
+
"format": {
|
|
2957
|
+
"description": "How the value is shown, as a type:'stat' widget's content.format: a style name, or { style, currency, currencyField, decimals, prefix, suffix }. Omitted, the plain localized count is shown. Numbers are formatted with Intl in the user's locale.",
|
|
2958
|
+
"oneOf": [
|
|
2959
|
+
{ "type": "string", "enum": ["number", "currency", "percent", "decimal", "duration-hours"] },
|
|
2960
|
+
{
|
|
2961
|
+
"type": "object",
|
|
2962
|
+
"additionalProperties": false,
|
|
2963
|
+
"properties": {
|
|
2964
|
+
"style": { "type": "string", "enum": ["number", "currency", "percent", "decimal", "duration-hours"] },
|
|
2965
|
+
"currency": { "type": "string", "description": "ISO-4217 code, or a token such as @config.currency or @object.currency." },
|
|
2966
|
+
"currencyField": { "type": "string", "description": "Field of the detail page's object that holds the currency (dot-path allowed)." },
|
|
2967
|
+
"decimals": { "type": "integer", "minimum": 0, "description": "Fraction digits." },
|
|
2968
|
+
"prefix": { "type": "string" },
|
|
2969
|
+
"suffix": { "type": "string" }
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
]
|
|
2973
|
+
},
|
|
2974
|
+
"currency": {
|
|
2975
|
+
"type": "string",
|
|
2976
|
+
"description": "Shorthand for format.currency: an ISO-4217 code, or a token such as @config.currency. Implies format 'currency'."
|
|
2977
|
+
},
|
|
2978
|
+
"currencyField": {
|
|
2979
|
+
"type": "string",
|
|
2980
|
+
"description": "Shorthand for format.currencyField: the field of the detail page's object that holds the currency, e.g. 'currency'. Implies format 'currency'. The currency is taken from the object, then currency, then the app's reporting currency (config currency), then EUR."
|
|
2955
2981
|
}
|
|
2956
2982
|
}
|
|
2957
2983
|
},
|
|
@@ -3458,14 +3484,30 @@
|
|
|
3458
3484
|
"actions": {
|
|
3459
3485
|
"type": "array",
|
|
3460
3486
|
"items": {
|
|
3461
|
-
"
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3487
|
+
"anyOf": [
|
|
3488
|
+
{
|
|
3489
|
+
"type": "object",
|
|
3490
|
+
"required": ["label"],
|
|
3491
|
+
"additionalProperties": true,
|
|
3492
|
+
"properties": {
|
|
3493
|
+
"label": { "type": "string", "minLength": 1 },
|
|
3494
|
+
"id": { "type": "string", "not": { "pattern": "^builtin:" } },
|
|
3495
|
+
"builtin": false
|
|
3496
|
+
}
|
|
3497
|
+
},
|
|
3498
|
+
{
|
|
3499
|
+
"type": "string",
|
|
3500
|
+
"enum": ["builtin:view", "builtin:edit", "builtin:copy", "builtin:delete"]
|
|
3501
|
+
}
|
|
3502
|
+
]
|
|
3467
3503
|
},
|
|
3468
|
-
"
|
|
3504
|
+
"allOf": [
|
|
3505
|
+
{ "contains": { "const": "builtin:view" }, "minContains": 0, "maxContains": 1 },
|
|
3506
|
+
{ "contains": { "const": "builtin:edit" }, "minContains": 0, "maxContains": 1 },
|
|
3507
|
+
{ "contains": { "const": "builtin:copy" }, "minContains": 0, "maxContains": 1 },
|
|
3508
|
+
{ "contains": { "const": "builtin:delete" }, "minContains": 0, "maxContains": 1 }
|
|
3509
|
+
],
|
|
3510
|
+
"description": "Declarative actions on a page. An object entry is governed ONLY by \"an object carrying a non-empty label\", deliberately: this key is polymorphic by page type (index rows dispatch $defs/action; detail pages also carry type='lifecycle-transition' entries), so one closed grammar here would reject working manifests. A label is what every dialect needs — CnRowActions renders an entry from its label and icon, so an entry without one became a full-height, clickable, EMPTY row in the overflow menu. An object is always an app action whatever its id: an id, when present, must be a string that does not start with 'builtin:', and the object may not set a 'builtin' key. On a type='index' page an entry may also be one of the placeholders \"builtin:view\", \"builtin:edit\", \"builtin:copy\" or \"builtin:delete\", each at most once, which puts that built-in row action at its position in the menu. A placeholder only places a built-in: config.actionToggles / the show*Action keys still decide whether it renders. Enabled built-ins the array does not place are appended after everything else in the order View, Edit, Copy, Delete. Any other string (a bare \"edit\", an unknown \"builtin:archive\") is refused, and placeholders are refused on every page type other than index."
|
|
3469
3511
|
},
|
|
3470
3512
|
"rowIndicators": {
|
|
3471
3513
|
"type": "array",
|
|
@@ -3732,6 +3774,10 @@
|
|
|
3732
3774
|
"type": "boolean",
|
|
3733
3775
|
"description": "Whether the built-in create / edit form dialog is enabled. Maps to CnIndexPage `showFormDialog`."
|
|
3734
3776
|
},
|
|
3777
|
+
"showViewAction": {
|
|
3778
|
+
"type": "boolean",
|
|
3779
|
+
"description": "Whether to add a View action to row actions. Maps to CnIndexPage `showViewAction`."
|
|
3780
|
+
},
|
|
3735
3781
|
"showEditAction": {
|
|
3736
3782
|
"type": "boolean",
|
|
3737
3783
|
"description": "Whether to add an Edit action to row actions. Maps to CnIndexPage `showEditAction`."
|
|
@@ -3824,6 +3870,24 @@
|
|
|
3824
3870
|
}
|
|
3825
3871
|
}
|
|
3826
3872
|
},
|
|
3873
|
+
{
|
|
3874
|
+
"description": "The \"builtin:<id>\" placeholders in config.actions place CnIndexPage's built-in row actions, so only a type='index' page may use them. On every other page type config.actions holds objects only; a placeholder there would validate and then render nothing (row-action-builtin-placement).",
|
|
3875
|
+
"if": {
|
|
3876
|
+
"not": {
|
|
3877
|
+
"properties": { "type": { "const": "index" } },
|
|
3878
|
+
"required": ["type"]
|
|
3879
|
+
}
|
|
3880
|
+
},
|
|
3881
|
+
"then": {
|
|
3882
|
+
"properties": {
|
|
3883
|
+
"config": {
|
|
3884
|
+
"properties": {
|
|
3885
|
+
"actions": { "items": { "type": "object" } }
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
},
|
|
3827
3891
|
{
|
|
3828
3892
|
"description": "tabInAddress puts the active tab of a record in the address. Only a detail page has a record and a tab strip, so the key is refused elsewhere rather than validating into a no-op (case-page-and-list-as-a-place).",
|
|
3829
3893
|
"if": {
|
|
@@ -156,6 +156,40 @@ export function mergeUserLayout(manifestLayout, record) {
|
|
|
156
156
|
return [...arranged, ...appended]
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The three calls a user layout needs: from a store that carries the
|
|
161
|
+
* `dashboardLayoutsPlugin` actions, or straight over user preferences.
|
|
162
|
+
*
|
|
163
|
+
* Both paths write through `writeUserPreference`, so a record lands on the
|
|
164
|
+
* server and in the browser mirror whichever one a page uses. CnDashboardPage
|
|
165
|
+
* reads its own grid's record through this, and the page views mixin reads
|
|
166
|
+
* each view's record through it, so the two cannot disagree about where a
|
|
167
|
+
* user's arrangement lives.
|
|
168
|
+
*
|
|
169
|
+
* @param {object|null} [store] A store with `loadDashboardLayout`, `saveDashboardLayout` and `resetDashboardLayout`, or null.
|
|
170
|
+
* @return {{ load: (appId: string, pageId: string) => Promise<object|null>, save: (appId: string, pageId: string, layout: Array<object>) => Promise<boolean>, reset: (appId: string, pageId: string) => Promise<boolean> }} The api.
|
|
171
|
+
* @spec openspec/changes/page-view-user-layouts/specs/view-switch-containers/spec.md#requirement-a-user-arranges-each-view-for-themselves
|
|
172
|
+
*/
|
|
173
|
+
export function resolveUserLayoutApi(store = null) {
|
|
174
|
+
if (store && typeof store.loadDashboardLayout === 'function') {
|
|
175
|
+
return {
|
|
176
|
+
load: (...a) => store.loadDashboardLayout(...a),
|
|
177
|
+
save: (...a) => store.saveDashboardLayout(...a),
|
|
178
|
+
reset: (...a) => store.resetDashboardLayout(...a),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
load: (appId, pageId) => readUserPreference(appId, dashboardLayoutKey(pageId), null),
|
|
184
|
+
save: (appId, pageId, layout) => writeUserPreference(
|
|
185
|
+
appId,
|
|
186
|
+
dashboardLayoutKey(pageId),
|
|
187
|
+
{ items: (Array.isArray(layout) ? layout : []).map(geometryOf).filter((entry) => entry.widgetId !== '') },
|
|
188
|
+
),
|
|
189
|
+
reset: (appId, pageId) => writeUserPreference(appId, dashboardLayoutKey(pageId), { items: [] }),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
159
193
|
/**
|
|
160
194
|
* Plugin definition.
|
|
161
195
|
*
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
* SPDX-License-Identifier: EUPL-1.2
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { getCanonicalLocale } from '@nextcloud/l10n'
|
|
6
7
|
import { resolveFilterValue } from './resolveFilterTokens.js'
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The format styles a stat or stats-block entry may name.
|
|
11
|
+
*
|
|
12
|
+
* @type {Array<string>}
|
|
13
|
+
*/
|
|
14
|
+
export const METRIC_FORMAT_STYLES = ['number', 'currency', 'percent', 'duration-hours', 'decimal']
|
|
15
|
+
|
|
8
16
|
/**
|
|
9
17
|
* Unwrap a possibly-ref-wrapped `cnAppConfig` inject into a plain object.
|
|
10
18
|
*
|
|
@@ -25,20 +33,25 @@ export function unwrapAppConfig(cnAppConfig) {
|
|
|
25
33
|
* Resolve `@config.<key>` tokens in a `content.format` spec's string fields.
|
|
26
34
|
*
|
|
27
35
|
* Each of `currency` / `prefix` / `suffix` may be a `@config.<key>` token (e.g.
|
|
28
|
-
* `currency: '@config.currency'`)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* `currency: '@config.currency'`), or an `@object.<field>` token read off the
|
|
37
|
+
* detail page's object when `object` is given. Tokens are resolved against
|
|
38
|
+
* `configCtx`; a token that stays unresolved (the config key is unset, or the
|
|
39
|
+
* config map has not been injected yet during a route transition) is DROPPED
|
|
40
|
+
* so the downstream literal default applies instead of a raw `@config.…`
|
|
41
|
+
* string leaking into `Intl.NumberFormat`.
|
|
33
42
|
*
|
|
34
43
|
* @param {object} format The raw `content.format` spec.
|
|
35
44
|
* @param {object} configCtx The page-level app-config map (from `unwrapAppConfig`).
|
|
45
|
+
* @param {object|null} [object] The detail page's object, for `@object.<field>` tokens.
|
|
36
46
|
* @return {object} A copy of `format` with its string tokens resolved/dropped.
|
|
37
47
|
*/
|
|
38
|
-
export function resolveConfigFormat(format, configCtx) {
|
|
48
|
+
export function resolveConfigFormat(format, configCtx, object = null) {
|
|
39
49
|
const fmt = format || {}
|
|
40
50
|
const out = { ...fmt }
|
|
41
|
-
const ctx = {
|
|
51
|
+
const ctx = {
|
|
52
|
+
config: (configCtx && typeof configCtx === 'object') ? configCtx : {},
|
|
53
|
+
object: (object && typeof object === 'object') ? object : null,
|
|
54
|
+
}
|
|
42
55
|
for (const key of ['currency', 'prefix', 'suffix']) {
|
|
43
56
|
const raw = fmt[key]
|
|
44
57
|
if (typeof raw !== 'string' || raw.charAt(0) !== '@') {
|
|
@@ -68,6 +81,114 @@ export function safeCurrencyCode(currency) {
|
|
|
68
81
|
: 'EUR'
|
|
69
82
|
}
|
|
70
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether a value is a three-letter currency code.
|
|
86
|
+
*
|
|
87
|
+
* @param {unknown} value The candidate.
|
|
88
|
+
* @return {boolean}
|
|
89
|
+
*/
|
|
90
|
+
function isCurrencyCode(value) {
|
|
91
|
+
return typeof value === 'string' && /^[A-Za-z]{3}$/.test(value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read a dot-path off an object.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} object The object.
|
|
98
|
+
* @param {string} path The path, e.g. `currency` or `price.currency`.
|
|
99
|
+
* @return {unknown} The value, or undefined.
|
|
100
|
+
*/
|
|
101
|
+
function readPath(object, path) {
|
|
102
|
+
return String(path).split('.').reduce((acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), object)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The currency a money value is shown in. In order:
|
|
107
|
+
*
|
|
108
|
+
* 1. `currencyField`: the field of the page's object that holds the currency
|
|
109
|
+
* (a detail page; e.g. a contract's own `currency`).
|
|
110
|
+
* 2. `currency`: a literal code, or a token (`@config.currency`,
|
|
111
|
+
* `@object.currency`).
|
|
112
|
+
* 3. The app's reporting currency, `currency` in the page's app config.
|
|
113
|
+
* 4. `EUR`.
|
|
114
|
+
*
|
|
115
|
+
* A step whose value is not a three-letter code is skipped, so an object
|
|
116
|
+
* without a currency falls back to the reporting currency instead of
|
|
117
|
+
* throwing in `Intl.NumberFormat`.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} format The format spec (`{ currency?, currencyField? }`).
|
|
120
|
+
* @param {object} configCtx The page-level app-config map.
|
|
121
|
+
* @param {object|null} [object] The detail page's object.
|
|
122
|
+
* @return {string} An upper-case three-letter code.
|
|
123
|
+
* @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-money-value-is-shown-in-the-currency-the-entry-names
|
|
124
|
+
*/
|
|
125
|
+
export function resolveFormatCurrency(format, configCtx, object = null) {
|
|
126
|
+
const fmt = format || {}
|
|
127
|
+
if (typeof fmt.currencyField === 'string' && fmt.currencyField !== '' && object && typeof object === 'object') {
|
|
128
|
+
const own = readPath(object, fmt.currencyField)
|
|
129
|
+
if (isCurrencyCode(own)) {
|
|
130
|
+
return own.toUpperCase()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const declared = resolveConfigFormat({ currency: fmt.currency }, configCtx, object).currency
|
|
134
|
+
if (isCurrencyCode(declared)) {
|
|
135
|
+
return declared.toUpperCase()
|
|
136
|
+
}
|
|
137
|
+
const reporting = configCtx && typeof configCtx === 'object' ? configCtx.currency : undefined
|
|
138
|
+
return safeCurrencyCode(reporting)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The format spec of a stat or stats-block entry in one shape.
|
|
143
|
+
*
|
|
144
|
+
* `format` may be a style name (`"currency"`) or an object (`{ style,
|
|
145
|
+
* currency, currencyField, decimals, prefix, suffix }`). A sibling `currency`
|
|
146
|
+
* or `currencyField` on the entry itself fills the same keys, and either one
|
|
147
|
+
* makes the style `currency` when none is named. Answers null when the entry
|
|
148
|
+
* declares no format at all, so the caller keeps its own rendering.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} entry The declaration (`{ format?, currency?, currencyField? }`).
|
|
151
|
+
* @return {object|null} The format spec, or null.
|
|
152
|
+
* @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value
|
|
153
|
+
*/
|
|
154
|
+
export function normalizeMetricFormat(entry) {
|
|
155
|
+
const source = entry && typeof entry === 'object' ? entry : {}
|
|
156
|
+
let fmt = null
|
|
157
|
+
if (typeof source.format === 'string' && source.format !== '') {
|
|
158
|
+
fmt = { style: source.format }
|
|
159
|
+
} else if (source.format && typeof source.format === 'object' && !Array.isArray(source.format)) {
|
|
160
|
+
fmt = { ...source.format }
|
|
161
|
+
}
|
|
162
|
+
for (const key of ['currency', 'currencyField']) {
|
|
163
|
+
if (typeof source[key] === 'string' && source[key] !== '') {
|
|
164
|
+
fmt = fmt || {}
|
|
165
|
+
if (fmt[key] === undefined) {
|
|
166
|
+
fmt[key] = source[key]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (fmt && !fmt.style && (fmt.currency || fmt.currencyField)) {
|
|
171
|
+
fmt.style = 'currency'
|
|
172
|
+
}
|
|
173
|
+
return fmt
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The user's locale for number formatting: the Nextcloud locale setting,
|
|
178
|
+
* else the browser's.
|
|
179
|
+
*
|
|
180
|
+
* @return {string|undefined} A BCP 47 tag, or undefined for the runtime default.
|
|
181
|
+
*/
|
|
182
|
+
export function metricLocale() {
|
|
183
|
+
try {
|
|
184
|
+
const tag = getCanonicalLocale()
|
|
185
|
+
// A tag Intl does not accept would throw a RangeError on every value.
|
|
186
|
+
return tag && Intl.NumberFormat.supportedLocalesOf([tag]).length > 0 ? tag : undefined
|
|
187
|
+
} catch {
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
71
192
|
/**
|
|
72
193
|
* Format a numeric value per a manifest `content.format` spec.
|
|
73
194
|
*
|
|
@@ -85,12 +206,18 @@ export function safeCurrencyCode(currency) {
|
|
|
85
206
|
* - `decimal` is a plain number with ONE fraction digit by default (the
|
|
86
207
|
* fleet KPIs' `toFixed(1)` convention), where `number` defaults to 0.
|
|
87
208
|
*
|
|
209
|
+
* The currency follows `resolveFormatCurrency`: the object's own currency
|
|
210
|
+
* (`currencyField`), then `currency`, then the app's reporting currency, then
|
|
211
|
+
* EUR. Numbers are formatted in the user's locale (`metricLocale`).
|
|
212
|
+
*
|
|
88
213
|
* @param {unknown} value The raw value to format.
|
|
89
|
-
* @param {object} format The `content.format` spec (`{ style, currency, decimals, prefix, suffix }`).
|
|
214
|
+
* @param {object} format The `content.format` spec (`{ style, currency, currencyField, decimals, prefix, suffix }`).
|
|
90
215
|
* @param {object} configCtx The page-level app-config map for `@config.<key>` resolution.
|
|
216
|
+
* @param {object|null} [object] The detail page's object, for `currencyField` and `@object.<field>`.
|
|
91
217
|
* @return {string} The formatted display string.
|
|
218
|
+
* @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-money-value-is-shown-in-the-currency-the-entry-names
|
|
92
219
|
*/
|
|
93
|
-
export function formatMetricValue(value, format, configCtx) {
|
|
220
|
+
export function formatMetricValue(value, format, configCtx, object = null) {
|
|
94
221
|
if (value === null || value === undefined) {
|
|
95
222
|
return '—'
|
|
96
223
|
}
|
|
@@ -99,7 +226,8 @@ export function formatMetricValue(value, format, configCtx) {
|
|
|
99
226
|
return String(value)
|
|
100
227
|
}
|
|
101
228
|
|
|
102
|
-
const fmt = resolveConfigFormat(format, configCtx)
|
|
229
|
+
const fmt = resolveConfigFormat(format, configCtx, object)
|
|
230
|
+
const locale = metricLocale()
|
|
103
231
|
// `duration-hours` and `decimal` default to ONE fraction digit; the
|
|
104
232
|
// other styles keep the pre-existing default of 0.
|
|
105
233
|
const oneDigitDefault = fmt.style === 'duration-hours' || fmt.style === 'decimal'
|
|
@@ -107,26 +235,26 @@ export function formatMetricValue(value, format, configCtx) {
|
|
|
107
235
|
|
|
108
236
|
let body
|
|
109
237
|
if (fmt.style === 'currency') {
|
|
110
|
-
body = new Intl.NumberFormat(
|
|
238
|
+
body = new Intl.NumberFormat(locale, {
|
|
111
239
|
style: 'currency',
|
|
112
|
-
currency:
|
|
240
|
+
currency: resolveFormatCurrency(format, configCtx, object),
|
|
113
241
|
minimumFractionDigits: decimals,
|
|
114
242
|
maximumFractionDigits: decimals,
|
|
115
243
|
}).format(num)
|
|
116
244
|
} else if (fmt.style === 'percent') {
|
|
117
245
|
// Values are stored as the literal percent (83.3), not a 0–1 ratio.
|
|
118
|
-
body = new Intl.NumberFormat(
|
|
246
|
+
body = new Intl.NumberFormat(locale, {
|
|
119
247
|
minimumFractionDigits: decimals,
|
|
120
248
|
maximumFractionDigits: decimals,
|
|
121
249
|
}).format(num) + '%'
|
|
122
250
|
} else if (fmt.style === 'duration-hours') {
|
|
123
251
|
// An hour count (e.g. mean resolution time): `42.5h`.
|
|
124
|
-
body = new Intl.NumberFormat(
|
|
252
|
+
body = new Intl.NumberFormat(locale, {
|
|
125
253
|
minimumFractionDigits: decimals,
|
|
126
254
|
maximumFractionDigits: decimals,
|
|
127
255
|
}).format(num) + 'h'
|
|
128
256
|
} else {
|
|
129
|
-
body = new Intl.NumberFormat(
|
|
257
|
+
body = new Intl.NumberFormat(locale, {
|
|
130
258
|
minimumFractionDigits: decimals,
|
|
131
259
|
maximumFractionDigits: decimals,
|
|
132
260
|
}).format(num)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The built-in row action ids, in their default order.
|
|
8
|
+
*
|
|
9
|
+
* @type {ReadonlyArray<string>}
|
|
10
|
+
*/
|
|
11
|
+
export const BUILTIN_ROW_ACTION_IDS = Object.freeze(['view', 'edit', 'copy', 'delete'])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The prefix that marks a row action entry as a placeholder for a built-in.
|
|
15
|
+
*
|
|
16
|
+
* @type {string}
|
|
17
|
+
*/
|
|
18
|
+
export const BUILTIN_PLACEHOLDER_PREFIX = 'builtin:'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether an entry is one of the four built-in placeholders (`"builtin:view"`, `"builtin:edit"`, `"builtin:copy"`, `"builtin:delete"`).
|
|
22
|
+
*
|
|
23
|
+
* @param {object|string} entry A declared row action entry.
|
|
24
|
+
* @return {boolean} True for a known placeholder string.
|
|
25
|
+
*/
|
|
26
|
+
export function isBuiltinPlaceholder(entry) {
|
|
27
|
+
return typeof entry === 'string'
|
|
28
|
+
&& entry.startsWith(BUILTIN_PLACEHOLDER_PREFIX)
|
|
29
|
+
&& BUILTIN_ROW_ACTION_IDS.includes(entry.slice(BUILTIN_PLACEHOLDER_PREFIX.length))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A short name for an entry in a warning message.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} action An app action object.
|
|
36
|
+
* @return {string} Its id, else its label, quoted.
|
|
37
|
+
*/
|
|
38
|
+
function describe(action) {
|
|
39
|
+
const name = (typeof action.id === 'string' && action.id !== '') ? action.id : action.label
|
|
40
|
+
return JSON.stringify(name === undefined ? '' : name)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a page's declared row actions and its enabled built-ins into the one ordered list every row surface renders.
|
|
45
|
+
*
|
|
46
|
+
* - An object is an app action, whatever its `id`; a `builtin` key on it is dropped, since only real built-ins carry that marker.
|
|
47
|
+
* - A placeholder (`"builtin:edit"`) puts that built-in at its position when it is enabled, and renders nothing when it is not: the toggle wins.
|
|
48
|
+
* - A repeated placeholder keeps its first position.
|
|
49
|
+
* - Any other entry is dropped.
|
|
50
|
+
* - Enabled built-ins the entries do not place are appended in the order `builtins` gives them.
|
|
51
|
+
*
|
|
52
|
+
* Every dropped or questionable entry is reported in `warnings`; the caller decides whether to log them.
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<object|string>} declared The declared entries: app action objects and placeholders.
|
|
55
|
+
* @param {Array<{id: string}>} builtins The enabled built-in actions, in default order.
|
|
56
|
+
* @param {object} [options] Options.
|
|
57
|
+
* @param {(action: object) => object} [options.prepare] Maps an app action to the object rendered for it.
|
|
58
|
+
* @return {{actions: Array<object>, warnings: Array<{code: string, message: string}>, deleteNotLast: boolean}} The ordered actions, the warnings, and whether the built-in Delete is enabled but not last.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveRowActions(declared, builtins, { prepare = (action) => action } = {}) {
|
|
61
|
+
const enabled = new Map()
|
|
62
|
+
for (const builtin of (Array.isArray(builtins) ? builtins : [])) {
|
|
63
|
+
if (builtin && typeof builtin.id === 'string') {
|
|
64
|
+
enabled.set(builtin.id, builtin)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const placed = new Set()
|
|
68
|
+
const actions = []
|
|
69
|
+
const warnings = []
|
|
70
|
+
|
|
71
|
+
for (const entry of (Array.isArray(declared) ? declared : [])) {
|
|
72
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
73
|
+
let action = entry
|
|
74
|
+
if (Object.hasOwn(entry, 'builtin')) {
|
|
75
|
+
const { builtin, ...rest } = entry
|
|
76
|
+
action = rest
|
|
77
|
+
warnings.push({
|
|
78
|
+
code: 'builtin-key-ignored',
|
|
79
|
+
message: `Action ${describe(entry)} sets "builtin", which only built-in actions carry; the key is ignored and the entry stays an app action.`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
actions.push(prepare(action))
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (isBuiltinPlaceholder(entry)) {
|
|
86
|
+
const id = entry.slice(BUILTIN_PLACEHOLDER_PREFIX.length)
|
|
87
|
+
if (placed.has(id)) {
|
|
88
|
+
warnings.push({
|
|
89
|
+
code: 'repeated-placeholder',
|
|
90
|
+
message: `"${entry}" is placed more than once; only its first position is used.`,
|
|
91
|
+
})
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
placed.add(id)
|
|
95
|
+
if (!enabled.has(id)) {
|
|
96
|
+
warnings.push({
|
|
97
|
+
code: 'disabled-placeholder',
|
|
98
|
+
message: `"${entry}" is placed but that built-in action is turned off, so it renders nothing. Its show*Action toggle decides whether it renders.`,
|
|
99
|
+
})
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
actions.push(enabled.get(id))
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
warnings.push({
|
|
106
|
+
code: 'invalid-entry',
|
|
107
|
+
message: `Ignoring action ${JSON.stringify(entry)}: actions must be objects with an id and a label, or one of ${BUILTIN_ROW_ACTION_IDS.map((id) => `"${BUILTIN_PLACEHOLDER_PREFIX}${id}"`).join(', ')}. To turn a built-in action on or off use the showViewAction / showEditAction / showCopyAction / showDeleteAction props.`,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const builtin of enabled.values()) {
|
|
112
|
+
if (!placed.has(builtin.id)) {
|
|
113
|
+
actions.push(builtin)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const deleteIndex = actions.findIndex((action) => action && action.builtin === true && action.id === 'delete')
|
|
118
|
+
const deleteNotLast = deleteIndex !== -1 && deleteIndex !== actions.length - 1
|
|
119
|
+
if (deleteNotLast) {
|
|
120
|
+
warnings.push({
|
|
121
|
+
code: 'delete-not-last',
|
|
122
|
+
message: `"${BUILTIN_PLACEHOLDER_PREFIX}delete" is not the last row action; it renders before ${actions.slice(deleteIndex + 1).map((a) => (a.builtin === true ? `"${BUILTIN_PLACEHOLDER_PREFIX}${a.id}"` : describe(a))).join(', ')}. Delete is normally last.`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { actions, warnings, deleteNotLast }
|
|
127
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* How a row action renders as a menu entry.
|
|
6
|
+
* CnRowActions and CnContextMenu both use these, so a row's actions menu and its right-click menu agree on which entries show and on each entry's key, testid and event payload.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from './visibleWhen.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether an action is a built-in row action (View, Edit, Copy, Delete).
|
|
13
|
+
*
|
|
14
|
+
* @param {object} action The action.
|
|
15
|
+
* @return {boolean} True for a built-in carrying an id.
|
|
16
|
+
*/
|
|
17
|
+
function isBuiltin(action) {
|
|
18
|
+
return !!action && action.builtin === true && typeof action.id === 'string' && action.id !== ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether an action shows for an item.
|
|
23
|
+
* A locally decidable `visibleWhen` that is false hides it; an endpoint or source condition is not decided here.
|
|
24
|
+
* Then `visible` (a boolean or `(item) => boolean`) applies; without it the action shows.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} action The action.
|
|
27
|
+
* @param {object|string|number|null} item The row or right-clicked item.
|
|
28
|
+
* @return {boolean} Whether the entry renders.
|
|
29
|
+
*/
|
|
30
|
+
export function isRowActionVisible(action, item) {
|
|
31
|
+
if (action.visibleWhen
|
|
32
|
+
&& isLocallyDecidableVisibleWhen(action.visibleWhen)
|
|
33
|
+
&& evaluateVisibleWhenLocal(action.visibleWhen, item) === false) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
if (action.visible === undefined) {
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
if (typeof action.visible === 'function') {
|
|
40
|
+
return !!action.visible(item)
|
|
41
|
+
}
|
|
42
|
+
return !!action.visible
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Slugify a label for a `data-testid` suffix: lowercase, kebab-case, non-alphanumerics stripped.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} label The label.
|
|
49
|
+
* @return {string} The slug.
|
|
50
|
+
*/
|
|
51
|
+
export function slugifyActionLabel(label) {
|
|
52
|
+
// One pass collapses every run of other characters to a single '-', so at
|
|
53
|
+
// most one '-' can sit at either end; trimming it by index keeps this
|
|
54
|
+
// linear. `/^-+|-+$/g` here was a ReDoS on long runs of '-' (CodeQL).
|
|
55
|
+
const slug = String(label || '')
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
58
|
+
const start = slug.startsWith('-') ? 1 : 0
|
|
59
|
+
const end = slug.length > start && slug.endsWith('-') ? slug.length - 1 : slug.length
|
|
60
|
+
return slug.slice(start, end)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The render key of an entry: `builtin:<id>` for a built-in, the label for an app action.
|
|
65
|
+
* An app action with `id: "edit"` therefore never shares a key with the built-in Edit.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} action The action.
|
|
68
|
+
* @return {string} The key.
|
|
69
|
+
*/
|
|
70
|
+
export function rowActionKey(action) {
|
|
71
|
+
return isBuiltin(action) ? `builtin:${action.id}` : action.label
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `data-testid` of an entry: `cn-action-item-<id>` for a built-in, in every locale, and the slug of the label for an app action.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} action The action.
|
|
78
|
+
* @return {string} The testid.
|
|
79
|
+
*/
|
|
80
|
+
export function rowActionTestId(action) {
|
|
81
|
+
return `cn-action-item-${isBuiltin(action) ? action.id : slugifyActionLabel(action.label)}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `action` event payload for an entry: `action` is the label, `id` is the action's id when it has one, and `builtin: true` marks a built-in.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} action The action.
|
|
88
|
+
* @param {object|string|number|null} row The row or right-clicked item.
|
|
89
|
+
* @return {{action: string, row: (object|string|number|null), id?: string, builtin?: true}} The payload.
|
|
90
|
+
*/
|
|
91
|
+
export function rowActionPayload(action, row) {
|
|
92
|
+
const payload = { action: action.label, row }
|
|
93
|
+
if (typeof action.id === 'string' && action.id !== '') {
|
|
94
|
+
payload.id = action.id
|
|
95
|
+
}
|
|
96
|
+
if (isBuiltin(action)) {
|
|
97
|
+
payload.builtin = true
|
|
98
|
+
}
|
|
99
|
+
return payload
|
|
100
|
+
}
|
|
@@ -97,6 +97,49 @@ export function fieldEvents(object, fields, now = new Date()) {
|
|
|
97
97
|
* @param {Date} [now] The reference moment.
|
|
98
98
|
* @return {Array<object>} The events.
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* The text at a path of a list entry, or '' when the path is unset or empty.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} row The list entry.
|
|
104
|
+
* @param {string|undefined} path The path to read.
|
|
105
|
+
* @return {string} The text.
|
|
106
|
+
*/
|
|
107
|
+
function textAt(row, path) {
|
|
108
|
+
if (!path) {
|
|
109
|
+
return ''
|
|
110
|
+
}
|
|
111
|
+
const value = readPath(row, path)
|
|
112
|
+
return value === null || value === undefined ? '' : String(value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Events from a list held on the object itself, such as a status history:
|
|
117
|
+
* `[{ field, dateField, labelField?, label?, detailField? }]`. Each entry of the
|
|
118
|
+
* list at `field` becomes one event, dated by `dateField`, labelled by its
|
|
119
|
+
* `labelField` value (else `label`), with `detailField` as the line under it.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} object The object.
|
|
122
|
+
* @param {Array<object>} lists The list configs.
|
|
123
|
+
* @param {Date} now The moment that separates past from upcoming.
|
|
124
|
+
* @return {Array<object>} The events.
|
|
125
|
+
* @spec openspec/changes/timeline-audit-trails-url/specs/timeline-widget/spec.md#requirement-a-list-on-the-object-becomes-dated-events
|
|
126
|
+
*/
|
|
127
|
+
export function listEvents(object, lists, now = new Date()) {
|
|
128
|
+
return (Array.isArray(lists) ? lists : [])
|
|
129
|
+
.filter((l) => l && typeof l.field === 'string' && typeof l.dateField === 'string')
|
|
130
|
+
.flatMap((l) => {
|
|
131
|
+
const rows = readPath(object, l.field)
|
|
132
|
+
return (Array.isArray(rows) ? rows : []).map((row, i) => makeEvent({
|
|
133
|
+
id: `list:${l.field}:${i}`,
|
|
134
|
+
raw: readPath(row, l.dateField),
|
|
135
|
+
label: textAt(row, l.labelField) || l.label || l.field,
|
|
136
|
+
detail: textAt(row, l.detailField),
|
|
137
|
+
source: 'list',
|
|
138
|
+
}, now))
|
|
139
|
+
})
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
}
|
|
142
|
+
|
|
100
143
|
export function relatedEvents(rows, config, now = new Date()) {
|
|
101
144
|
const cfg = config || {}
|
|
102
145
|
const dateField = cfg.dateField || '@self.created'
|