@conduction/nextcloud-vue 2.62.0 → 2.63.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/CnAdminActionCard/CnAdminActionCard.vue.js +10 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue.js.map +1 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js +189 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js.map +1 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js +72 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js.map +1 -0
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +271 -32
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +99 -9
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +39 -18
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +53 -37
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +2 -2
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js +10 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js.map +1 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +293 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +189 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -0
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +216 -8
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +118 -50
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +2 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js +3 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js.map +1 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js +1 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +234 -9
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +96 -57
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +51 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/selfModeActions.js +3 -3
- package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +36 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js +121 -12
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js.map +1 -1
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js +3 -1
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +326 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +112 -26
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js +10 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +337 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js +102 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js +34 -0
- package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js.map +1 -0
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +131 -8
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js +40 -38
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +8 -6
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
- package/dist/esm/composables/useDependencyCheck.js +65 -0
- package/dist/esm/composables/useDependencyCheck.js.map +1 -0
- package/dist/esm/composables/useWalkthrough.js +218 -6
- package/dist/esm/composables/useWalkthrough.js.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +43 -0
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +43 -0
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +449 -0
- package/dist/esm/store/useObjectStore.js +8 -0
- package/dist/esm/store/useObjectStore.js.map +1 -1
- package/dist/esm/utils/columnFilters.js +311 -0
- package/dist/esm/utils/columnFilters.js.map +1 -0
- package/dist/esm/utils/groupAutocomplete.js +123 -0
- package/dist/esm/utils/groupAutocomplete.js.map +1 -0
- package/dist/esm/utils/libraryWidgetKeys.js +2 -0
- package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
- package/dist/esm/utils/pickerOptions.js +198 -0
- package/dist/esm/utils/pickerOptions.js.map +1 -0
- package/dist/esm/utils/schema.js +83 -7
- package/dist/esm/utils/schema.js.map +1 -1
- package/dist/esm/utils/timelineEvents.js +174 -0
- package/dist/esm/utils/timelineEvents.js.map +1 -0
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/esm/utils/walkthroughSignals.js +88 -0
- package/dist/esm/utils/walkthroughSignals.js.map +1 -0
- package/dist/nextcloud-vue.cjs.js +9369 -5193
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +449 -0
- package/l10n/en.json +43 -0
- package/l10n/nl.json +43 -0
- package/package.json +1 -1
- package/src/components/CnAdminActionCard/CnAdminActionCard.vue +234 -0
- package/src/components/CnAdminActionCard/index.js +4 -0
- package/src/components/CnAppNav/CnAppNav.vue +271 -32
- package/src/components/CnAppRoot/CnAppRoot.vue +129 -15
- package/src/components/CnChoiceCards/CnChoiceCards.vue +56 -31
- package/src/components/CnDashboardPage/CnDashboardPage.vue +2 -2
- package/src/components/CnDataTable/CnColumnFilterPopover.vue +524 -0
- package/src/components/CnDataTable/CnDataTable.md +3 -0
- package/src/components/CnDataTable/CnDataTable.vue +260 -4
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +2 -2
- package/src/components/CnFieldHelper/CnFieldHelper.vue +4 -2
- package/src/components/CnFormDialog/CnFormDialog.vue +290 -31
- package/src/components/CnIndexPage/CnIndexPage.vue +55 -2
- package/src/components/CnIndexPage/selfModeActions.js +3 -3
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +39 -1
- package/src/components/CnResourceSelect/CnResourceSelect.vue +123 -12
- package/src/components/CnSetupWizard/CnSetupWizard.vue +392 -3
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +477 -0
- package/src/components/CnTimelineWidget/dashboardRegistration.js +32 -0
- package/src/components/CnTimelineWidget/index.js +4 -0
- package/src/components/CnWalkthrough/CnWalkthrough.vue +143 -10
- package/src/components/CnWidgetGrid/builtInWidgets.js +2 -0
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
- package/src/components/index.js +2 -0
- package/src/composables/useDependencyCheck.js +61 -0
- package/src/composables/useWalkthrough.js +217 -5
- package/src/css/dashboard.css +49 -0
- package/src/css/detail-page.css +19 -0
- package/src/css/table.css +81 -0
- package/src/index.js +2 -0
- package/src/schemas/app-manifest-v2.schema.json +23 -1
- package/src/store/useObjectStore.js +8 -0
- package/src/utils/columnFilters.js +324 -0
- package/src/utils/groupAutocomplete.js +119 -0
- package/src/utils/libraryWidgetKeys.js +2 -0
- package/src/utils/pickerOptions.js +194 -0
- package/src/utils/schema.js +83 -7
- package/src/utils/timelineEvents.js +171 -0
- package/src/utils/validateManifestV2.compiled.js +2 -2
- package/src/utils/walkthroughSignals.js +94 -0
package/src/css/dashboard.css
CHANGED
|
@@ -80,3 +80,52 @@
|
|
|
80
80
|
.cn-dashboard-list__item:hover {
|
|
81
81
|
background: var(--color-background-hover);
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/* "Install <app>" placeholder in a widget whose app is missing.
|
|
85
|
+
*
|
|
86
|
+
* NcEmptyContent centres its content with `justify-content: center`. In a
|
|
87
|
+
* two-row dashboard tile (about 150px inside an `overflow: hidden` wrapper)
|
|
88
|
+
* the icon, name, description and button are taller than the tile, and plain
|
|
89
|
+
* `center` overflows on BOTH sides, so the top ran out of the widget and the
|
|
90
|
+
* name was cut off. `safe center` falls back to the start edge when the
|
|
91
|
+
* content does not fit, the spacing is compact, and what still does not fit
|
|
92
|
+
* scrolls instead of being clipped. Shared by CnDashboardPage and
|
|
93
|
+
* CnDetailWidgetHost (`cn-requires-app`). The class is doubled to outrank
|
|
94
|
+
* NcEmptyContent's own scoped rules (`.empty-content[data-v-…]`) whatever
|
|
95
|
+
* order the stylesheets load in. */
|
|
96
|
+
.cn-requires-app.cn-requires-app.empty-content,
|
|
97
|
+
.cn-requires-app.cn-requires-app .empty-content {
|
|
98
|
+
justify-content: safe center;
|
|
99
|
+
min-height: 0;
|
|
100
|
+
max-height: 100%;
|
|
101
|
+
overflow: auto;
|
|
102
|
+
margin: 0;
|
|
103
|
+
padding: calc(2 * var(--default-grid-baseline, 4px));
|
|
104
|
+
gap: var(--default-grid-baseline, 4px);
|
|
105
|
+
box-sizing: border-box;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cn-requires-app.cn-requires-app .empty-content__icon {
|
|
109
|
+
width: auto;
|
|
110
|
+
height: auto;
|
|
111
|
+
min-height: 0;
|
|
112
|
+
margin: 0;
|
|
113
|
+
opacity: 0.7;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.cn-requires-app.cn-requires-app .empty-content__name {
|
|
117
|
+
margin: 0;
|
|
118
|
+
font-size: var(--default-font-size, 15px);
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
text-align: center;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.cn-requires-app.cn-requires-app .empty-content__description {
|
|
124
|
+
margin: 0;
|
|
125
|
+
text-align: center;
|
|
126
|
+
font-size: 0.9em;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.cn-requires-app.cn-requires-app .empty-content__action {
|
|
130
|
+
margin-top: var(--default-grid-baseline, 4px);
|
|
131
|
+
}
|
package/src/css/detail-page.css
CHANGED
|
@@ -106,6 +106,25 @@
|
|
|
106
106
|
flex-shrink: 0;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/* Every control in the header sits on one line at one height. The lifecycle
|
|
110
|
+
bar carries `margin: 0 0 8px` for its place above a page body; in the
|
|
111
|
+
header that margin pushed its buttons up and made the row 8px taller, so
|
|
112
|
+
the Edit button beside it read as the bigger one. A consumer's actions
|
|
113
|
+
component can bring its own margins too, so they are reset here as well. */
|
|
114
|
+
.cn-detail-page .cn-detail-page__header-actions .cn-lifecycle-actions {
|
|
115
|
+
margin: 0;
|
|
116
|
+
flex-wrap: nowrap;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.cn-detail-page .cn-detail-page__header-actions > * {
|
|
120
|
+
margin-block: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.cn-detail-page .cn-detail-page__header-actions .button-vue {
|
|
124
|
+
min-height: var(--default-clickable-area);
|
|
125
|
+
block-size: var(--default-clickable-area);
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
/* The sentence a refused lock acquire left behind, under the locked banner. */
|
|
110
129
|
.cn-detail-page__lock-refusal {
|
|
111
130
|
margin: 0 0 calc(3 * var(--default-grid-baseline));
|
package/src/css/table.css
CHANGED
|
@@ -295,6 +295,87 @@
|
|
|
295
295
|
outline-offset: -2px;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
/* Header filter button (CnDataTable `filterable`). 24px hit area with the
|
|
299
|
+
16px icon; the filled icon plus the primary colour marks an active filter,
|
|
300
|
+
so the state never rests on colour alone. */
|
|
301
|
+
.cn-table-header__filter {
|
|
302
|
+
display: inline-flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
justify-content: center;
|
|
305
|
+
width: 24px;
|
|
306
|
+
min-width: 24px;
|
|
307
|
+
height: 24px;
|
|
308
|
+
min-height: 24px;
|
|
309
|
+
margin: 0 0 0 4px;
|
|
310
|
+
padding: 0;
|
|
311
|
+
border: none;
|
|
312
|
+
border-radius: var(--border-radius, 4px);
|
|
313
|
+
background: transparent;
|
|
314
|
+
color: var(--color-text-maxcontrast);
|
|
315
|
+
vertical-align: middle;
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.cn-table-header__filter:hover {
|
|
320
|
+
background-color: var(--color-background-hover);
|
|
321
|
+
color: var(--color-main-text);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.cn-table-header__filter:focus-visible {
|
|
325
|
+
outline: 2px solid var(--color-main-text);
|
|
326
|
+
outline-offset: 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.cn-table-header__filter--active {
|
|
330
|
+
color: var(--color-primary-element);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* Active header filters above the table, one removable chip each. */
|
|
334
|
+
.cn-data-table__filter-chips {
|
|
335
|
+
display: flex;
|
|
336
|
+
flex-wrap: wrap;
|
|
337
|
+
gap: 6px;
|
|
338
|
+
margin: 0 0 8px;
|
|
339
|
+
padding: 0;
|
|
340
|
+
list-style: none;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.cn-data-table__filter-chip {
|
|
344
|
+
display: inline-flex;
|
|
345
|
+
align-items: center;
|
|
346
|
+
gap: 4px;
|
|
347
|
+
padding: 2px 4px 2px 10px;
|
|
348
|
+
border: 1px solid var(--color-border-dark);
|
|
349
|
+
border-radius: var(--border-radius-pill, 16px);
|
|
350
|
+
background-color: var(--color-background-dark);
|
|
351
|
+
color: var(--color-main-text);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.cn-data-table__filter-chip-remove {
|
|
355
|
+
display: inline-flex;
|
|
356
|
+
align-items: center;
|
|
357
|
+
justify-content: center;
|
|
358
|
+
width: 24px;
|
|
359
|
+
min-width: 24px;
|
|
360
|
+
height: 24px;
|
|
361
|
+
min-height: 24px;
|
|
362
|
+
margin: 0;
|
|
363
|
+
padding: 0;
|
|
364
|
+
border: none;
|
|
365
|
+
border-radius: 50%;
|
|
366
|
+
background: transparent;
|
|
367
|
+
color: var(--color-main-text);
|
|
368
|
+
cursor: pointer;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.cn-data-table__filter-chip-remove:hover {
|
|
372
|
+
background-color: var(--color-background-hover);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.cn-data-table__filter-chip-remove:focus-visible {
|
|
376
|
+
outline: 2px solid var(--color-main-text);
|
|
377
|
+
}
|
|
378
|
+
|
|
298
379
|
/* Multi-column sort priority badge (1, 2, 3), rendered only when two or more rendered, sortable columns are sorted. */
|
|
299
380
|
.cn-table-sort-badge {
|
|
300
381
|
display: inline-flex;
|
package/src/index.js
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
CnActionsBar,
|
|
36
36
|
CnActionsMenu,
|
|
37
37
|
CnAddWidgetModal,
|
|
38
|
+
CnAdminActionCard,
|
|
38
39
|
CnAdminSettingsShell,
|
|
39
40
|
CnAdvancedFormDialog,
|
|
40
41
|
CnAppLoading,
|
|
@@ -268,6 +269,7 @@ export {
|
|
|
268
269
|
CnTileWidget,
|
|
269
270
|
CnTimelineStages,
|
|
270
271
|
CnTimelineView,
|
|
272
|
+
CnTimelineWidget,
|
|
271
273
|
CnTranslatedBadge,
|
|
272
274
|
CnTreeView,
|
|
273
275
|
CnUserActionMenu,
|
|
@@ -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.45.0",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"required": ["$schema", "version"],
|
|
9
9
|
"allOf": [
|
|
@@ -97,6 +97,8 @@
|
|
|
97
97
|
"suggestFrom": { "type": "string", "description": "choice: the configKey of an earlier choice this step pre-fills a suggestion from. When that value is set and the user has not yet picked here, the option matching suggestMap[parentValue] is auto-selected (still user-overridable)." },
|
|
98
98
|
"suggestMap": { "type": "object", "description": "choice: map of suggestFrom value → suggested option value. Used to auto-suggest a default (e.g. RGS template suggested from organisation type).", "additionalProperties": { "type": "string" } },
|
|
99
99
|
"multiple": { "type": "boolean", "default": false, "description": "choice: allow multi-select." },
|
|
100
|
+
"loadAction": { "type": "string", "minLength": 1, "description": "choice with display cards: an actionId each option card loads itself with. Every card except the one whose value is `none` gets its own Load button, which POSTs { dataset: <value> } to /api/setup/action/{loadAction}, spins while it runs and shows the result on that card. A successful load also selects the card. With loadAction the separate run-action step that loaded the picked dataset is no longer needed; manifests that keep it still work." },
|
|
101
|
+
"requires": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "App ids this step needs. When any of them is not installed and enabled, the wizard skips the step and the summary shows it as skipped, naming the missing apps. Do not combine with required: true; setup status would keep reporting the skipped step as unmet." },
|
|
100
102
|
"action": { "type": "string", "description": "run-action: actionId POSTed to /api/setup/action/{action}." },
|
|
101
103
|
"onDemand": { "type": "boolean", "default": false, "description": "run-action: the step runs only when the user clicks Run, never automatically, and is never outstanding work (it does not open the wizard or decide where a resumed wizard starts). The summary ticks it only when its action succeeded in the current session and shows it as not run otherwise, whatever the server reports. For destructive steps an app reports as done on purpose so they never auto-run, such as removing example data." },
|
|
102
104
|
"healthCheck": { "type": "boolean", "default": false, "description": "summary: render a health recap." },
|
|
@@ -1173,6 +1175,11 @@
|
|
|
1173
1175
|
"type": "string",
|
|
1174
1176
|
"description": "i18n translation key resolved by the consuming app's t() function at render time."
|
|
1175
1177
|
},
|
|
1178
|
+
"translateLabel": {
|
|
1179
|
+
"type": "boolean",
|
|
1180
|
+
"default": true,
|
|
1181
|
+
"description": "Whether CnAppNav runs `label` through the translate function. Set false for a user-authored title (e.g. a catalog name) that must render as written even when it equals a translation key such as 'Search'. Rendered as text either way, never as HTML."
|
|
1182
|
+
},
|
|
1176
1183
|
"icon": {
|
|
1177
1184
|
"type": "string",
|
|
1178
1185
|
"description": "CSS class for the icon (e.g. 'icon-checkmark') or MDI component name."
|
|
@@ -1186,6 +1193,11 @@
|
|
|
1186
1193
|
"additionalProperties": { "type": ["string", "number", "boolean"] },
|
|
1187
1194
|
"description": "Optional vue-router query parameters merged into the target route link (e.g. a filter like { caseType: '<uuid>' }). Deep-links a nav entry to a pre-filtered index page."
|
|
1188
1195
|
},
|
|
1196
|
+
"params": {
|
|
1197
|
+
"type": "object",
|
|
1198
|
+
"additionalProperties": { "type": ["string", "number"], "minLength": 1 },
|
|
1199
|
+
"description": "Optional vue-router route params for a parameterised route (e.g. { catalogSlug: 'news' } for a page at '/catalogs/:catalogSlug'). String values must be non-empty. CnAppNav links to { name: route, params, query }. The entry is active only while every declared param equals the current route's param (compared as strings), so sibling entries on one route that differ in params are never marked together. On pages whose path sits below its page path with these params filled in, it is active too: by the same params check when those routes carry the same param names, otherwise as the one entry marked below its route."
|
|
1200
|
+
},
|
|
1189
1201
|
"order": {
|
|
1190
1202
|
"type": "integer",
|
|
1191
1203
|
"description": "Display order in the menu. Items without an order render last."
|
|
@@ -1259,6 +1271,11 @@
|
|
|
1259
1271
|
"properties": {
|
|
1260
1272
|
"id": { "type": "string" },
|
|
1261
1273
|
"label": { "type": "string" },
|
|
1274
|
+
"translateLabel": {
|
|
1275
|
+
"type": "boolean",
|
|
1276
|
+
"default": true,
|
|
1277
|
+
"description": "Same as menuItem.translateLabel: false renders `label` as written instead of translating it."
|
|
1278
|
+
},
|
|
1262
1279
|
"icon": { "type": "string" },
|
|
1263
1280
|
"route": { "type": "string" },
|
|
1264
1281
|
"query": {
|
|
@@ -1266,6 +1283,11 @@
|
|
|
1266
1283
|
"additionalProperties": { "type": ["string", "number", "boolean"] },
|
|
1267
1284
|
"description": "Optional vue-router query parameters merged into the target route link (e.g. a filter like { caseType: '<uuid>' }). Deep-links a nav entry to a pre-filtered index page."
|
|
1268
1285
|
},
|
|
1286
|
+
"params": {
|
|
1287
|
+
"type": "object",
|
|
1288
|
+
"additionalProperties": { "type": ["string", "number"], "minLength": 1 },
|
|
1289
|
+
"description": "Optional vue-router route params for a parameterised route; string values must be non-empty. Same contract as menuItem.params: part of the link, and the entry is active only while every declared param equals the current route's param."
|
|
1290
|
+
},
|
|
1269
1291
|
"order": { "type": "integer" },
|
|
1270
1292
|
"permission": { "type": "string" },
|
|
1271
1293
|
"section": {
|
|
@@ -5,6 +5,7 @@ import { genericError, networkError, parseResponseError } from '../utils/errors.
|
|
|
5
5
|
import { normalizeFacets } from '../utils/facets.js'
|
|
6
6
|
import { buildHeaders, buildQueryString, capitalize, prefixUrl } from '../utils/headers.js'
|
|
7
7
|
import { extractId } from '../utils/id.js'
|
|
8
|
+
import { dispatchObjectCreated } from '../utils/walkthroughSignals.js'
|
|
8
9
|
import { mergePluginActions, mergePluginGetters, mergePluginState } from './pluginMerge.js'
|
|
9
10
|
import { liveUpdatesPlugin } from './plugins/liveUpdates.js'
|
|
10
11
|
|
|
@@ -830,6 +831,13 @@ const baseActions = {
|
|
|
830
831
|
[type]: { ...(this.objects[type] || {}), [savedId]: data },
|
|
831
832
|
}
|
|
832
833
|
|
|
834
|
+
// Every create through the store lets an `advanceOn:
|
|
835
|
+
// object-created` walkthrough step advance, whichever form made it.
|
|
836
|
+
if (!isUpdate) {
|
|
837
|
+
const config = this.objectTypeRegistry[type] || {}
|
|
838
|
+
dispatchObjectCreated({ register: config.registerSlug || config.register, schema: config.schemaSlug || config.schema, object: data })
|
|
839
|
+
}
|
|
840
|
+
|
|
833
841
|
return data
|
|
834
842
|
} catch (error) {
|
|
835
843
|
this.errors = {
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-column sorting and filtering for table headers.
|
|
8
|
+
*
|
|
9
|
+
* Pure helpers: which column can sort, which can filter and how, and the
|
|
10
|
+
* translation between a header filter's state and the OpenRegister query
|
|
11
|
+
* parameters the facet sidebar already sends. A header filter writes into the
|
|
12
|
+
* SAME active-filter map (`{ paramKey: values[] }`) the sidebar uses, so the
|
|
13
|
+
* fetch, the route query and saved views need nothing new.
|
|
14
|
+
*
|
|
15
|
+
* Parameter shapes, as OpenRegister reads them:
|
|
16
|
+
* - equality and "any of": `key=value`, `key[]=a&key[]=b`
|
|
17
|
+
* - ranges: `key[gte]=from`, `key[lte]=to`
|
|
18
|
+
*
|
|
19
|
+
* @module utils/columnFilters
|
|
20
|
+
* @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { schemaRefSlug } from './schemaRefSlug.js'
|
|
24
|
+
|
|
25
|
+
/** Column keys that mark a value computed on the client, never stored. */
|
|
26
|
+
const COMPUTED_MARKERS = ['aggregate', 'compute', 'computed', 'virtual']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalise a `$ref` to the referenced schema slug, or null.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} ref A `$ref` value.
|
|
32
|
+
* @return {string|number|null} The slug.
|
|
33
|
+
*/
|
|
34
|
+
function refSlug(ref) {
|
|
35
|
+
if (typeof ref === 'number' && !Number.isNaN(ref)) {
|
|
36
|
+
return ref
|
|
37
|
+
}
|
|
38
|
+
if (typeof ref !== 'string' || ref === '') {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
const tail = ref.includes('/') ? ref.substring(ref.lastIndexOf('/') + 1) : ref
|
|
42
|
+
const slug = tail === '' ? '' : schemaRefSlug(tail)
|
|
43
|
+
return slug !== '' ? slug : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The schema property behind a column, or null when the column has none.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} column The column definition.
|
|
50
|
+
* @param {object|null} schema The table's schema.
|
|
51
|
+
* @return {object|null} The property.
|
|
52
|
+
*/
|
|
53
|
+
export function columnProperty(column, schema) {
|
|
54
|
+
if (!column || !schema || !schema.properties || typeof column.key !== 'string') {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
const prop = schema.properties[column.key]
|
|
58
|
+
return (prop && typeof prop === 'object') ? prop : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a column computes its value on the client, so the server cannot
|
|
63
|
+
* sort or filter on it.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} column The column definition.
|
|
66
|
+
* @return {boolean} True for a computed column.
|
|
67
|
+
*/
|
|
68
|
+
function isComputedColumn(column) {
|
|
69
|
+
return COMPUTED_MARKERS.some((marker) => column[marker] !== undefined && column[marker] !== null && column[marker] !== false)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a column header sorts.
|
|
74
|
+
*
|
|
75
|
+
* `sortable: false` always wins, and so does an explicit `sortable: true`.
|
|
76
|
+
* Otherwise, with a schema, a column sorts when a stored schema property backs
|
|
77
|
+
* it: OpenRegister orders on schema properties, so a widget or computed column
|
|
78
|
+
* with no property behind it stays unsortable. Without a schema nothing says
|
|
79
|
+
* the server can sort, so only an explicit flag makes the column sortable,
|
|
80
|
+
* which is how such tables behaved before.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} column The column definition.
|
|
83
|
+
* @param {object|null} schema The table's schema.
|
|
84
|
+
* @return {boolean} True when the header sorts.
|
|
85
|
+
* @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md#requirement-every-backed-column-sorts-by-default
|
|
86
|
+
*/
|
|
87
|
+
export function isColumnSortable(column, schema) {
|
|
88
|
+
if (!column || typeof column !== 'object') {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
if (column.sortable === false) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
if (column.sortable === true) {
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
if (!schema || isComputedColumn(column)) {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
const prop = columnProperty(column, schema)
|
|
101
|
+
return !!prop && prop.type !== 'object'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Display options for an enum filter: schema `enum` (or `items.enum`) with
|
|
106
|
+
* `x-enum-labels`, else a column `enum` hint, else a badge column's colour
|
|
107
|
+
* map keys.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} column The column definition.
|
|
110
|
+
* @param {object|null} prop The schema property.
|
|
111
|
+
* @return {Array<{value: string, label: string}>} The options, or [].
|
|
112
|
+
*/
|
|
113
|
+
function enumOptions(column, prop) {
|
|
114
|
+
const p = prop || {}
|
|
115
|
+
const labels = p.enumLabels || p['x-enum-labels'] || (p.items && (p.items.enumLabels || p.items['x-enum-labels'])) || {}
|
|
116
|
+
let values = null
|
|
117
|
+
if (Array.isArray(p.enum) && p.enum.length > 0) {
|
|
118
|
+
values = p.enum
|
|
119
|
+
} else if (p.items && Array.isArray(p.items.enum) && p.items.enum.length > 0) {
|
|
120
|
+
values = p.items.enum
|
|
121
|
+
} else if (Array.isArray(column.enum) && column.enum.length > 0) {
|
|
122
|
+
values = column.enum
|
|
123
|
+
} else if (column.widget === 'badge' && column.widgetProps && column.widgetProps.colorMap) {
|
|
124
|
+
values = Object.keys(column.widgetProps.colorMap)
|
|
125
|
+
}
|
|
126
|
+
if (!values) {
|
|
127
|
+
return []
|
|
128
|
+
}
|
|
129
|
+
return values
|
|
130
|
+
.filter((v) => v !== null && v !== undefined && v !== '')
|
|
131
|
+
.map((v) => ({ value: String(v), label: String(labels[v] !== undefined ? labels[v] : v) }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The reference a column points at, for a searchable object filter: a schema
|
|
136
|
+
* `$ref` (or `items.$ref`), else an `fkResolve` cell widget's config.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} column The column definition.
|
|
139
|
+
* @param {object|null} prop The schema property.
|
|
140
|
+
* @return {{schema: (string|number), register: string, labelField: string}|null} The reference.
|
|
141
|
+
*/
|
|
142
|
+
function referenceTarget(column, prop) {
|
|
143
|
+
const widgetProps = (column.widget === 'fkResolve' && column.widgetProps) || null
|
|
144
|
+
const p = prop || {}
|
|
145
|
+
const schemaRef = refSlug(p.$ref) ?? (p.items ? refSlug(p.items.$ref) : null)
|
|
146
|
+
const schema = schemaRef ?? (widgetProps && widgetProps.schema ? widgetProps.schema : null)
|
|
147
|
+
if (schema === null || schema === undefined || schema === '') {
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
const register = (widgetProps && widgetProps.register)
|
|
151
|
+
|| p['x-external-register']
|
|
152
|
+
|| (p.items && p.items['x-external-register'])
|
|
153
|
+
|| ''
|
|
154
|
+
const labelField = (widgetProps && widgetProps.labelField) || ''
|
|
155
|
+
return { schema, register, labelField }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How a column filters, or null when it does not.
|
|
160
|
+
*
|
|
161
|
+
* Kinds: `enum` (checkbox list), `boolean` (yes, no, any), `reference`
|
|
162
|
+
* (searchable list of the referenced objects), `number` and `date` (from and
|
|
163
|
+
* to), and `string` (equals). `filterable: false` on the column turns it
|
|
164
|
+
* off. A column with no schema property behind it only filters when it
|
|
165
|
+
* carries an enum hint (`enum`, or a badge widget's colour map), because the
|
|
166
|
+
* server cannot filter on a value it does not store.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} column The column definition.
|
|
169
|
+
* @param {object|null} schema The table's schema.
|
|
170
|
+
* @return {{key: string, kind: string, label: string, options: Array<{value: string, label: string}>, reference: object|null, dateTime: boolean}|null} The filter definition.
|
|
171
|
+
* @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md#requirement-every-backed-column-filters-from-its-header
|
|
172
|
+
*/
|
|
173
|
+
export function columnFilterDef(column, schema) {
|
|
174
|
+
if (!column || typeof column !== 'object' || typeof column.key !== 'string' || column.key === '') {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
if (column.filterable === false || isComputedColumn(column)) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
const prop = columnProperty(column, schema)
|
|
181
|
+
const base = { key: column.key, label: column.label || (prop && prop.title) || column.key, options: [], reference: null, dateTime: false }
|
|
182
|
+
|
|
183
|
+
const options = enumOptions(column, prop)
|
|
184
|
+
if (options.length > 0) {
|
|
185
|
+
return { ...base, kind: 'enum', options }
|
|
186
|
+
}
|
|
187
|
+
if (!prop) {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
const reference = referenceTarget(column, prop)
|
|
191
|
+
if (reference) {
|
|
192
|
+
return { ...base, kind: 'reference', reference }
|
|
193
|
+
}
|
|
194
|
+
const type = Array.isArray(prop.type) ? prop.type.find((t) => t !== 'null') : prop.type
|
|
195
|
+
const format = prop.format || ''
|
|
196
|
+
if (type === 'boolean') {
|
|
197
|
+
return { ...base, kind: 'boolean' }
|
|
198
|
+
}
|
|
199
|
+
if (type === 'number' || type === 'integer') {
|
|
200
|
+
return { ...base, kind: 'number' }
|
|
201
|
+
}
|
|
202
|
+
if (type === 'string' && (format === 'date' || format === 'date-time')) {
|
|
203
|
+
return { ...base, kind: 'date', dateTime: format === 'date-time' }
|
|
204
|
+
}
|
|
205
|
+
if (type === 'string') {
|
|
206
|
+
return { ...base, kind: 'string' }
|
|
207
|
+
}
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read one active-filter entry as a list of strings.
|
|
213
|
+
*
|
|
214
|
+
* @param {unknown} raw An active-filter value: an array, a scalar, or absent.
|
|
215
|
+
* @return {Array<string>} The values.
|
|
216
|
+
*/
|
|
217
|
+
function asList(raw) {
|
|
218
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
219
|
+
return []
|
|
220
|
+
}
|
|
221
|
+
return (Array.isArray(raw) ? raw : [raw]).filter((v) => v !== undefined && v !== null && v !== '').map((v) => String(v))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The parameter keys one column filter owns.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} def A filter definition from `columnFilterDef`.
|
|
228
|
+
* @return {Array<string>} The keys.
|
|
229
|
+
*/
|
|
230
|
+
export function columnFilterParamKeys(def) {
|
|
231
|
+
if (!def) {
|
|
232
|
+
return []
|
|
233
|
+
}
|
|
234
|
+
if (def.kind === 'number' || def.kind === 'date') {
|
|
235
|
+
return [`${def.key}[gte]`, `${def.key}[lte]`]
|
|
236
|
+
}
|
|
237
|
+
return [def.key]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A column filter's current state, read from the active-filter map.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} def A filter definition.
|
|
244
|
+
* @param {object} activeFilters The active-filter map (`{ paramKey: values }`).
|
|
245
|
+
* @return {object} `{ values }` for enum and reference, `{ value }` for boolean and string, `{ from, to }` for number and date.
|
|
246
|
+
*/
|
|
247
|
+
export function columnFilterState(def, activeFilters) {
|
|
248
|
+
const filters = (activeFilters && typeof activeFilters === 'object') ? activeFilters : {}
|
|
249
|
+
if (!def) {
|
|
250
|
+
return {}
|
|
251
|
+
}
|
|
252
|
+
if (def.kind === 'enum' || def.kind === 'reference') {
|
|
253
|
+
return { values: asList(filters[def.key]) }
|
|
254
|
+
}
|
|
255
|
+
if (def.kind === 'number' || def.kind === 'date') {
|
|
256
|
+
const from = asList(filters[`${def.key}[gte]`])[0] || ''
|
|
257
|
+
const to = asList(filters[`${def.key}[lte]`])[0] || ''
|
|
258
|
+
// A date-time column is queried up to the end of the day; the input
|
|
259
|
+
// shows the date the person picked.
|
|
260
|
+
return {
|
|
261
|
+
from: def.kind === 'date' ? from.slice(0, 10) : from,
|
|
262
|
+
to: def.kind === 'date' ? to.slice(0, 10) : to,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { value: asList(filters[def.key])[0] || '' }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether a column filter is active.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} def A filter definition.
|
|
272
|
+
* @param {object} activeFilters The active-filter map.
|
|
273
|
+
* @return {boolean} True when any of its parameters is set.
|
|
274
|
+
*/
|
|
275
|
+
export function isColumnFilterActive(def, activeFilters) {
|
|
276
|
+
const filters = (activeFilters && typeof activeFilters === 'object') ? activeFilters : {}
|
|
277
|
+
return columnFilterParamKeys(def).some((key) => asList(filters[key]).length > 0)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The active-filter entries a column filter state produces. Every key the
|
|
282
|
+
* column owns is present; an empty list clears that key, so changing a range
|
|
283
|
+
* from "from and to" to "from" drops the old upper bound.
|
|
284
|
+
*
|
|
285
|
+
* @param {object} def A filter definition.
|
|
286
|
+
* @param {object} state The column filter state (see `columnFilterState`).
|
|
287
|
+
* @return {object} `{ paramKey: values[] }`.
|
|
288
|
+
* @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md#requirement-a-header-filter-speaks-the-sidebars-query-language
|
|
289
|
+
*/
|
|
290
|
+
export function columnFilterParams(def, state) {
|
|
291
|
+
const s = state || {}
|
|
292
|
+
if (!def) {
|
|
293
|
+
return {}
|
|
294
|
+
}
|
|
295
|
+
if (def.kind === 'enum' || def.kind === 'reference') {
|
|
296
|
+
return { [def.key]: asList(s.values) }
|
|
297
|
+
}
|
|
298
|
+
if (def.kind === 'number' || def.kind === 'date') {
|
|
299
|
+
const from = s.from === undefined || s.from === null ? '' : String(s.from).trim()
|
|
300
|
+
let to = s.to === undefined || s.to === null ? '' : String(s.to).trim()
|
|
301
|
+
if (def.kind === 'date' && def.dateTime && to !== '' && to.length === 10) {
|
|
302
|
+
to = `${to}T23:59:59`
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
[`${def.key}[gte]`]: from === '' ? [] : [from],
|
|
306
|
+
[`${def.key}[lte]`]: to === '' ? [] : [to],
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (def.kind === 'boolean') {
|
|
310
|
+
return { [def.key]: (s.value === 'true' || s.value === 'false') ? [s.value] : [] }
|
|
311
|
+
}
|
|
312
|
+
const value = s.value === undefined || s.value === null ? '' : String(s.value).trim()
|
|
313
|
+
return { [def.key]: value === '' ? [] : [value] }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Clear-all entries for one column: every key it owns, emptied.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} def A filter definition.
|
|
320
|
+
* @return {object} `{ paramKey: [] }`.
|
|
321
|
+
*/
|
|
322
|
+
export function clearedColumnFilterParams(def) {
|
|
323
|
+
return Object.fromEntries(columnFilterParamKeys(def).map((key) => [key, []]))
|
|
324
|
+
}
|