@conduction/nextcloud-vue 2.56.0-beta.12 → 2.56.0-beta.14
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/CnActionsBar/CnActionsBar.vue2.js +19 -0
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +101 -5
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +23 -7
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +125 -4
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +439 -381
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +4 -4
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +142 -26
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +195 -124
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
- package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +84 -11
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +3 -2
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +335 -313
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +5 -11
- 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/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +22 -3
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +10 -3
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/composables/useDependencyCheck.js +44 -1
- package/dist/esm/composables/useDependencyCheck.js.map +1 -1
- package/dist/esm/composables/useListView.js +43 -1
- package/dist/esm/composables/useListView.js.map +1 -1
- package/dist/esm/composables/useSetupStatus.js +26 -3
- package/dist/esm/composables/useSetupStatus.js.map +1 -1
- package/dist/esm/l10n/en.json.js +7 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +7 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/mixins/pageViews.js +400 -0
- package/dist/esm/mixins/pageViews.js.map +1 -0
- package/dist/esm/nextcloud-vue.css +202 -2
- package/dist/esm/store/plugins/liveUpdates.js +19 -9
- package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
- package/dist/esm/store/useObjectStore.js +138 -42
- package/dist/esm/store/useObjectStore.js.map +1 -1
- package/dist/esm/utils/actionsDispatcher.js +25 -2
- package/dist/esm/utils/actionsDispatcher.js.map +1 -1
- package/dist/esm/utils/columnFilters.js +27 -3
- package/dist/esm/utils/columnFilters.js.map +1 -1
- package/dist/esm/utils/schema.js +40 -2
- package/dist/esm/utils/schema.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 +2857 -1335
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +202 -2
- package/l10n/en.json +7 -1
- package/l10n/nl.json +7 -1
- package/package.json +1 -1
- package/src/components/CnActionsBar/CnActionsBar.md +2 -1
- package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
- package/src/components/CnAppNav/CnAppNav.vue +150 -9
- package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +194 -10
- package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
- package/src/components/CnDataTable/CnDataTable.md +1 -1
- package/src/components/CnDataTable/CnDataTable.vue +4 -4
- package/src/components/CnDetailPage/CnDetailPage.md +5 -1
- package/src/components/CnDetailPage/CnDetailPage.vue +208 -11
- package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
- package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
- package/src/components/CnHeaderWidget/CnHeaderWidget.vue +100 -13
- package/src/components/CnIndexPage/CnIndexPage.md +2 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +47 -1
- package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
- package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
- package/src/components/CnSetupWizard/CnSetupWizard.vue +5 -11
- package/src/components/CnStagesWidget/CnStagesWidget.vue +9 -0
- package/src/components/CnStatWidget/CnStatWidget.vue +33 -5
- package/src/composables/useDependencyCheck.js +43 -0
- package/src/composables/useListView.js +43 -1
- package/src/composables/useSetupStatus.js +26 -3
- package/src/css/actions-bar.css +20 -2
- package/src/css/detail-page.css +27 -0
- package/src/css/kpi-card.css +47 -0
- package/src/mixins/pageViews.js +396 -0
- package/src/schemas/app-manifest-v2.schema.json +53 -4
- package/src/schemas/app-manifest.schema.json +1 -1
- package/src/store/plugins/liveUpdates.js +19 -9
- package/src/store/useObjectStore.js +137 -41
- package/src/utils/actionsDispatcher.js +25 -2
- package/src/utils/columnFilters.js +29 -2
- package/src/utils/schema.js +40 -2
- 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.49.0",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"required": ["$schema", "version"],
|
|
9
9
|
"allOf": [
|
|
@@ -98,7 +98,7 @@
|
|
|
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
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.
|
|
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. Setup status then counts the step as not applicable, neither met nor unmet, so it never reopens the wizard or gates the app. This holds for a required step too." },
|
|
102
102
|
"action": { "type": "string", "description": "run-action: actionId POSTed to /api/setup/action/{action}." },
|
|
103
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." },
|
|
104
104
|
"healthCheck": { "type": "boolean", "default": false, "description": "summary: render a health recap." },
|
|
@@ -236,7 +236,7 @@
|
|
|
236
236
|
},
|
|
237
237
|
"settingsLabel": {
|
|
238
238
|
"type": "string",
|
|
239
|
-
"description": "Override label for the settings foldout's gear button. Defaults to '
|
|
239
|
+
"description": "Override label for the settings foldout's gear button. Defaults to 'Advanced'."
|
|
240
240
|
},
|
|
241
241
|
"card": {
|
|
242
242
|
"type": "object",
|
|
@@ -272,6 +272,11 @@
|
|
|
272
272
|
"href": { "type": "string" }
|
|
273
273
|
}
|
|
274
274
|
},
|
|
275
|
+
"footer": {
|
|
276
|
+
"type": "array",
|
|
277
|
+
"items": { "type": "string", "minLength": 1 },
|
|
278
|
+
"description": "Declare the navigation footer (zuiddrecht-pixel-gaps-2): an ordered list of `section: \"footer\"` menu entry ids, plus the reserved ids `help` (the `nav.help` entry) and `settings` (the settings foldout). Only the named footer entries render there, in that order; footer entries left out move into the settings foldout so their pages stay reachable. `settings` first puts the foldout above the list (the board's `[\"settings\", \"help\"]`). Omitted, the footer renders as before: help, every footer entry, then the foldout."
|
|
279
|
+
},
|
|
275
280
|
"primaryAction": {
|
|
276
281
|
"$ref": "#/$defs/primaryAction",
|
|
277
282
|
"description": "App-wide default primary action rendered as an NcAppNavigationNew button above the main menu list. A page-scoped pages[].primaryAction for the current route wins over this default."
|
|
@@ -3603,6 +3608,39 @@
|
|
|
3603
3608
|
"default": true,
|
|
3604
3609
|
"description": "type='dashboard': `false` drops the header row (title, description, header actions, edit toggle) for a page that opens with its own heading, such as a greeting widget; the title stays as a visually hidden heading (zuiddrecht-pixel-gaps). Maps to CnDashboardPage `showHeader`."
|
|
3605
3610
|
},
|
|
3611
|
+
"views": {
|
|
3612
|
+
"type": "array",
|
|
3613
|
+
"minItems": 1,
|
|
3614
|
+
"description": "type='dashboard' | type='detail': views of the page, each its own widget grid behind one segmented control (\"My work | My team\") (view-switch-containers). The chosen view renders in a region below the page's own grid, drawn with the same grid and widgets; the page's own `widgets` and `layout` stay above it. The switch sits in the page header, or in a greeting header widget whose `content.views.options[]` carry `view` instead of `route`. The chosen view is in the address (`?view=<id>`) and in the user's preferences; the address wins. A view with nothing to draw shows `emptyText`, else a default sentence. On a detail page, views replace the automatic Data and Related body. Maps to CnDashboardPage / CnDetailPage `views`.",
|
|
3615
|
+
"items": {
|
|
3616
|
+
"type": "object",
|
|
3617
|
+
"required": ["id", "label"],
|
|
3618
|
+
"additionalProperties": false,
|
|
3619
|
+
"properties": {
|
|
3620
|
+
"id": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$", "description": "The view id, written to the address as `?view=<id>`." },
|
|
3621
|
+
"label": { "type": "string", "minLength": 1, "description": "The option label and the region's accessible name (through the app's translations)." },
|
|
3622
|
+
"icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name, reserved for an icon-bearing switch." },
|
|
3623
|
+
"emptyText": { "type": "string", "minLength": 1, "description": "The sentence shown when the view has nothing to draw." },
|
|
3624
|
+
"widgets": { "type": "array", "description": "The view's widget definitions, the same shape as the page's `widgets`.", "items": { "type": "object", "additionalProperties": true } },
|
|
3625
|
+
"layout": { "type": "array", "description": "The view's grid layout, the same shape as the page's `layout`.", "items": { "type": "object", "additionalProperties": true } }
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
},
|
|
3629
|
+
"defaultView": {
|
|
3630
|
+
"type": "string",
|
|
3631
|
+
"minLength": 1,
|
|
3632
|
+
"description": "type='dashboard' | type='detail': the id of the view chosen when neither the address nor the user's stored choice names one; without it the first view (view-switch-containers). Maps to `defaultView`."
|
|
3633
|
+
},
|
|
3634
|
+
"viewsLabel": {
|
|
3635
|
+
"type": "string",
|
|
3636
|
+
"minLength": 1,
|
|
3637
|
+
"description": "type='dashboard' | type='detail': the accessible name of the page's view switch, e.g. \"Whose work\"; defaults to \"View\" (view-switch-containers). Maps to `viewsLabel`."
|
|
3638
|
+
},
|
|
3639
|
+
"showWidgetActions": {
|
|
3640
|
+
"type": "boolean",
|
|
3641
|
+
"default": true,
|
|
3642
|
+
"description": "type='dashboard': `false` drops the overflow Actions menu from every widget that does not set `showActions` itself, for a dashboard whose widget headers carry a header link and nothing else (zuiddrecht-pixel-gaps-2). Maps to CnDashboardPage `showWidgetActions`."
|
|
3643
|
+
},
|
|
3606
3644
|
"showTitle": {
|
|
3607
3645
|
"type": "boolean",
|
|
3608
3646
|
"default": false,
|
|
@@ -3626,9 +3664,20 @@
|
|
|
3626
3664
|
"label": { "type": "string", "minLength": 1 },
|
|
3627
3665
|
"route": { "type": "string" },
|
|
3628
3666
|
"params": { "type": "object", "additionalProperties": true },
|
|
3629
|
-
"href": { "type": "string" }
|
|
3667
|
+
"href": { "type": "string" },
|
|
3668
|
+
"icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name (e.g. `Home`) drawn as the first crumb instead of the label, which then becomes its accessible name (zuiddrecht-pixel-gaps-2). Without it the label shows as text." }
|
|
3630
3669
|
}
|
|
3631
3670
|
},
|
|
3671
|
+
"headerCard": {
|
|
3672
|
+
"type": "boolean",
|
|
3673
|
+
"default": false,
|
|
3674
|
+
"description": "type='detail': draw the header (pills, title, actions) as a bordered card on the surface colour, as the board's case header (zuiddrecht-pixel-gaps-2). Maps to CnDetailPage `headerCard`."
|
|
3675
|
+
},
|
|
3676
|
+
"headerWidget": {
|
|
3677
|
+
"type": "string",
|
|
3678
|
+
"minLength": 1,
|
|
3679
|
+
"description": "type='detail': the id of a widget in `widgets` to render inside the header on its own row under the title, without a card of its own, e.g. the stages bars (zuiddrecht-pixel-gaps-2). The widget leaves the body grid. Maps to CnDetailPage `headerWidget`."
|
|
3680
|
+
},
|
|
3632
3681
|
"mapConfig": {
|
|
3633
3682
|
"type": "object",
|
|
3634
3683
|
"additionalProperties": true,
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"settingsLabel": {
|
|
52
52
|
"type": "string",
|
|
53
|
-
"description": "Override label for the settings foldout's gear button. Defaults to '
|
|
53
|
+
"description": "Override label for the settings foldout's gear button. Defaults to 'Advanced'."
|
|
54
54
|
},
|
|
55
55
|
"primaryAction": {
|
|
56
56
|
"$ref": "#/$defs/primaryAction",
|
|
@@ -432,19 +432,29 @@ export function liveUpdatesPlugin(opts = {}) {
|
|
|
432
432
|
const collectionInFlight = new Map()
|
|
433
433
|
|
|
434
434
|
const originalFetchCollection = store.fetchCollection.bind(store)
|
|
435
|
-
store.fetchCollection = async function dedupedFetchCollection(type, params = {}) {
|
|
435
|
+
store.fetchCollection = async function dedupedFetchCollection(type, params = {}, options = {}) {
|
|
436
436
|
if (!store.__liveDedupActive) {
|
|
437
|
-
return originalFetchCollection(type, params)
|
|
437
|
+
return originalFetchCollection(type, params, options)
|
|
438
438
|
}
|
|
439
439
|
const key = collectionDedupKey(type, params)
|
|
440
|
-
|
|
441
|
-
|
|
440
|
+
let entry = collectionInFlight.get(key)
|
|
441
|
+
if (!entry) {
|
|
442
|
+
// The shared request reports into its own sink, copied to
|
|
443
|
+
// each caller's `options.outcome` below, so a caller that
|
|
444
|
+
// joined it learns that request's result too.
|
|
445
|
+
const outcome = {}
|
|
446
|
+
const promise = originalFetchCollection(type, params, { ...options, outcome }).finally(() => {
|
|
447
|
+
collectionInFlight.delete(key)
|
|
448
|
+
})
|
|
449
|
+
entry = { promise, outcome }
|
|
450
|
+
collectionInFlight.set(key, entry)
|
|
442
451
|
}
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
452
|
+
const results = await entry.promise
|
|
453
|
+
const sink = options && options.outcome
|
|
454
|
+
if (sink && typeof sink === 'object' && 'error' in entry.outcome) {
|
|
455
|
+
sink.error = entry.outcome.error
|
|
456
|
+
}
|
|
457
|
+
return results
|
|
448
458
|
}
|
|
449
459
|
},
|
|
450
460
|
}
|
|
@@ -264,6 +264,27 @@ const baseActions = {
|
|
|
264
264
|
this.facets = { ...this.facets, [slug]: {} }
|
|
265
265
|
},
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Give a registered type a second schema path segment to try once when
|
|
269
|
+
* its own answers 404 (see `_fetchWithSchemaFallback`). Set by
|
|
270
|
+
* `resolveObjectOpType` for a schema title it kebab-cased, with the
|
|
271
|
+
* title as written. A type that is not registered is left alone.
|
|
272
|
+
*
|
|
273
|
+
* @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-schema-title-falls-back-to-its-written-form
|
|
274
|
+
* @param {string} slug The type slug
|
|
275
|
+
* @param {string} schema The fallback schema path segment
|
|
276
|
+
*/
|
|
277
|
+
setSchemaFallback(slug, schema) {
|
|
278
|
+
const config = this.objectTypeRegistry[slug]
|
|
279
|
+
if (!config || typeof schema !== 'string' || schema === '') {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
this.objectTypeRegistry = {
|
|
283
|
+
...this.objectTypeRegistry,
|
|
284
|
+
[slug]: { ...config, schemaFallback: schema },
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
|
|
267
288
|
/**
|
|
268
289
|
* Unregister an object type and clean up all its state.
|
|
269
290
|
*
|
|
@@ -433,11 +454,12 @@ const baseActions = {
|
|
|
433
454
|
*
|
|
434
455
|
* @param {string} type The type slug
|
|
435
456
|
* @param {string|null} [id] Optional object ID
|
|
457
|
+
* @param {string|null} [schema] Schema path segment to use instead of the type's own
|
|
436
458
|
* @return {string} Full API URL path
|
|
437
459
|
*/
|
|
438
|
-
_buildUrl(type, id = null) {
|
|
460
|
+
_buildUrl(type, id = null, schema = null) {
|
|
439
461
|
const config = this._getTypeConfig(type)
|
|
440
|
-
let url = `${this._options.baseUrl}/${config.register}/${config.schema}`
|
|
462
|
+
let url = `${this._options.baseUrl}/${config.register}/${schema ?? config.schema}`
|
|
441
463
|
if (id) {
|
|
442
464
|
url += `/${id}`
|
|
443
465
|
}
|
|
@@ -458,11 +480,12 @@ const baseActions = {
|
|
|
458
480
|
* @param {string} type The type slug
|
|
459
481
|
* @param {object} [params] Extra query parameters
|
|
460
482
|
* @param {string|null} [id] Optional object ID
|
|
483
|
+
* @param {string|null} [schema] Schema path segment to use instead of the type's own
|
|
461
484
|
* @return {string} Full API URL path including query string
|
|
462
485
|
*/
|
|
463
|
-
_buildUrlWithParams(type, params = {}, id = null) {
|
|
486
|
+
_buildUrlWithParams(type, params = {}, id = null, schema = null) {
|
|
464
487
|
const config = this._getTypeConfig(type)
|
|
465
|
-
let url = `${this._options.baseUrl}/${config.register}/${config.schema}`
|
|
488
|
+
let url = `${this._options.baseUrl}/${config.register}/${schema ?? config.schema}`
|
|
466
489
|
if (id) {
|
|
467
490
|
url += `/${id}`
|
|
468
491
|
}
|
|
@@ -472,6 +495,54 @@ const baseActions = {
|
|
|
472
495
|
return url
|
|
473
496
|
},
|
|
474
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Fetch for a type, retrying ONCE with its `schemaFallback` when the
|
|
500
|
+
* schema path segment answers 404. A type without a fallback (every type
|
|
501
|
+
* not registered by `resolveObjectOpType` for a kebab-cased title) makes
|
|
502
|
+
* exactly the one request it always made. When the retry succeeds the
|
|
503
|
+
* fallback becomes the type's schema, so later calls go straight to it;
|
|
504
|
+
* when it does not, the first answer is returned and nothing changes.
|
|
505
|
+
*
|
|
506
|
+
* @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-schema-title-falls-back-to-its-written-form
|
|
507
|
+
* @param {string} type The type slug
|
|
508
|
+
* @param {function((string|null)): string} makeUrl Builds the URL; called with
|
|
509
|
+
* null for the type's own schema, or with the fallback segment.
|
|
510
|
+
* @param {object} init The fetch options.
|
|
511
|
+
* @return {Promise<Response>} The response to use.
|
|
512
|
+
*/
|
|
513
|
+
async _fetchWithSchemaFallback(type, makeUrl, init) {
|
|
514
|
+
// `makeUrl` wraps `_buildUrl` / `_buildUrlWithParams`, so `url` is built
|
|
515
|
+
// from the prefixed `_options.baseUrl` like every other request here.
|
|
516
|
+
let url = makeUrl(null)
|
|
517
|
+
const response = await fetch(url, init)
|
|
518
|
+
if (!response || response.status !== 404) {
|
|
519
|
+
return response
|
|
520
|
+
}
|
|
521
|
+
const config = this.objectTypeRegistry[type]
|
|
522
|
+
const fallback = config && config.schemaFallback
|
|
523
|
+
if (!fallback || String(fallback) === String(config.schema)) {
|
|
524
|
+
return response
|
|
525
|
+
}
|
|
526
|
+
let retry
|
|
527
|
+
try {
|
|
528
|
+
url = makeUrl(fallback)
|
|
529
|
+
retry = await fetch(url, init)
|
|
530
|
+
} catch {
|
|
531
|
+
return response
|
|
532
|
+
}
|
|
533
|
+
if (!retry || !retry.ok) {
|
|
534
|
+
discardResponseBody(retry)
|
|
535
|
+
return response
|
|
536
|
+
}
|
|
537
|
+
discardResponseBody(response)
|
|
538
|
+
const { schemaFallback: _, ...rest } = config
|
|
539
|
+
this.objectTypeRegistry = {
|
|
540
|
+
...this.objectTypeRegistry,
|
|
541
|
+
[type]: { ...rest, schema: fallback },
|
|
542
|
+
}
|
|
543
|
+
return retry
|
|
544
|
+
},
|
|
545
|
+
|
|
475
546
|
/**
|
|
476
547
|
* Clear the error state for a type.
|
|
477
548
|
*
|
|
@@ -585,9 +656,12 @@ const baseActions = {
|
|
|
585
656
|
*
|
|
586
657
|
* @param {string} type The registered type slug
|
|
587
658
|
* @param {object} [params] Query parameters (_limit, _page, _search, _order, filters)
|
|
659
|
+
* @param {object} [options] Call options
|
|
660
|
+
* @param {{error?: object|null}} [options.outcome] Opt-in sink for THIS call's own result: `error` is set to the error it records on `errors[type]`, or `null` on success. Unlike `errors[type]`, which every call for the type shares, a later call cannot overwrite it.
|
|
588
661
|
* @return {Promise<Array>} The fetched collection (also stored in state)
|
|
589
662
|
*/
|
|
590
|
-
async fetchCollection(type, params = {}) {
|
|
663
|
+
async fetchCollection(type, params = {}, options = {}) {
|
|
664
|
+
const outcome = (options && typeof options.outcome === 'object' && options.outcome) || null
|
|
591
665
|
this.loading = { ...this.loading, [type]: true }
|
|
592
666
|
this.errors = { ...this.errors, [type]: null }
|
|
593
667
|
|
|
@@ -604,17 +678,23 @@ const baseActions = {
|
|
|
604
678
|
}
|
|
605
679
|
}
|
|
606
680
|
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
681
|
+
const response = await this._fetchWithSchemaFallback(
|
|
682
|
+
type,
|
|
683
|
+
(schema) => this._buildUrlWithParams(type, fetchParams, null, schema),
|
|
684
|
+
{
|
|
685
|
+
method: 'GET',
|
|
686
|
+
headers: this._buildHeaders(),
|
|
687
|
+
},
|
|
688
|
+
)
|
|
613
689
|
|
|
614
690
|
if (!response.ok) {
|
|
615
|
-
|
|
691
|
+
const failure = await parseResponseError(response, type)
|
|
692
|
+
this.errors = { ...this.errors, [type]: failure }
|
|
693
|
+
if (outcome) {
|
|
694
|
+
outcome.error = failure
|
|
695
|
+
}
|
|
616
696
|
// eslint-disable-next-line no-console -- diagnostic for a failure this code already degrades from
|
|
617
|
-
console.error(`Error fetching ${type} collection:`,
|
|
697
|
+
console.error(`Error fetching ${type} collection:`, failure)
|
|
618
698
|
return []
|
|
619
699
|
}
|
|
620
700
|
|
|
@@ -638,13 +718,17 @@ const baseActions = {
|
|
|
638
718
|
this.facets = { ...this.facets, [type]: transformed }
|
|
639
719
|
}
|
|
640
720
|
|
|
721
|
+
if (outcome) {
|
|
722
|
+
outcome.error = null
|
|
723
|
+
}
|
|
641
724
|
return results
|
|
642
725
|
} catch (error) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
726
|
+
const failure = error.name === 'TypeError'
|
|
727
|
+
? networkError(error)
|
|
728
|
+
: genericError(error)
|
|
729
|
+
this.errors = { ...this.errors, [type]: failure }
|
|
730
|
+
if (outcome) {
|
|
731
|
+
outcome.error = failure
|
|
648
732
|
}
|
|
649
733
|
// eslint-disable-next-line no-console -- diagnostic for a failure this code already degrades from
|
|
650
734
|
console.error(`Error fetching ${type} collection:`, error)
|
|
@@ -675,11 +759,14 @@ const baseActions = {
|
|
|
675
759
|
*/
|
|
676
760
|
async fetchCollectionForOptions(type, params = {}) {
|
|
677
761
|
try {
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
762
|
+
const response = await this._fetchWithSchemaFallback(
|
|
763
|
+
type,
|
|
764
|
+
(schema) => this._buildUrlWithParams(type, params, null, schema),
|
|
765
|
+
{
|
|
766
|
+
method: 'GET',
|
|
767
|
+
headers: this._buildHeaders(),
|
|
768
|
+
},
|
|
769
|
+
)
|
|
683
770
|
|
|
684
771
|
if (!response.ok) {
|
|
685
772
|
discardResponseBody(response)
|
|
@@ -734,10 +821,14 @@ const baseActions = {
|
|
|
734
821
|
this.errors = { ...this.errors, [type]: null }
|
|
735
822
|
|
|
736
823
|
try {
|
|
737
|
-
const response = await
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
824
|
+
const response = await this._fetchWithSchemaFallback(
|
|
825
|
+
type,
|
|
826
|
+
(schema) => (schema === null ? url : this._buildUrl(type, id, schema)),
|
|
827
|
+
{
|
|
828
|
+
method: 'GET',
|
|
829
|
+
headers: this._buildHeaders(),
|
|
830
|
+
},
|
|
831
|
+
)
|
|
741
832
|
|
|
742
833
|
if (!response.ok) {
|
|
743
834
|
this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
|
|
@@ -805,16 +896,19 @@ const baseActions = {
|
|
|
805
896
|
|
|
806
897
|
try {
|
|
807
898
|
const isUpdate = !!objectData.id
|
|
808
|
-
const url = isUpdate
|
|
809
|
-
? this._buildUrl(type, objectData.id)
|
|
810
|
-
: this._buildUrl(type)
|
|
811
899
|
const method = isUpdate ? 'PUT' : 'POST'
|
|
812
900
|
|
|
813
|
-
const response = await
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
901
|
+
const response = await this._fetchWithSchemaFallback(
|
|
902
|
+
type,
|
|
903
|
+
(schema) => (isUpdate
|
|
904
|
+
? this._buildUrl(type, objectData.id, schema)
|
|
905
|
+
: this._buildUrl(type, null, schema)),
|
|
906
|
+
{
|
|
907
|
+
method,
|
|
908
|
+
headers: this._buildHeaders(),
|
|
909
|
+
body: JSON.stringify(objectData),
|
|
910
|
+
},
|
|
911
|
+
)
|
|
818
912
|
|
|
819
913
|
if (!response.ok) {
|
|
820
914
|
this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
|
|
@@ -866,12 +960,14 @@ const baseActions = {
|
|
|
866
960
|
this.errors = { ...this.errors, [type]: null }
|
|
867
961
|
|
|
868
962
|
try {
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
963
|
+
const response = await this._fetchWithSchemaFallback(
|
|
964
|
+
type,
|
|
965
|
+
(schema) => this._buildUrl(type, id, schema),
|
|
966
|
+
{
|
|
967
|
+
method: 'DELETE',
|
|
968
|
+
headers: this._buildHeaders(),
|
|
969
|
+
},
|
|
970
|
+
)
|
|
875
971
|
|
|
876
972
|
if (!response.ok) {
|
|
877
973
|
this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
|
|
@@ -100,13 +100,33 @@ export function isExternalActionTarget(target) {
|
|
|
100
100
|
* would otherwise register — and fetch — a 404 (defect 7, learniq round
|
|
101
101
|
* 1). Idempotent for a caller that already passes a correct slug.
|
|
102
102
|
*
|
|
103
|
+
* A caller that already holds the schema SLUG (not a `$ref` title) passes
|
|
104
|
+
* `{ exactSchema: true }`: the slug is then used exactly as given. Kebab-casing
|
|
105
|
+
* a real camelCase slug (`productCategory` -> `product-category`) asks the
|
|
106
|
+
* objects API for a schema that does not exist and 404s (pipelinq review,
|
|
107
|
+
* round two).
|
|
108
|
+
*
|
|
109
|
+
* @spec openspec/changes/review-round-two/specs/schema-utilities/spec.md
|
|
110
|
+
*
|
|
111
|
+
* A title is not always registered under its kebab form: learniq holds
|
|
112
|
+
* `LearniqSettings` and `SubjectTeacherAssignment` as written. So a type
|
|
113
|
+
* registered here for a value the kebab rule CHANGED also carries the value
|
|
114
|
+
* as written as its `schemaFallback`, and the store retries with it once
|
|
115
|
+
* when the kebab form answers 404 (keeping it from then on). The kebab form
|
|
116
|
+
* is still tried first, so a schema registered under it costs nothing.
|
|
117
|
+
*
|
|
118
|
+
* @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-schema-title-falls-back-to-its-written-form
|
|
103
119
|
* @param {object} store The object store instance (useObjectStore shape).
|
|
104
120
|
* @param {{register: (string|number), schema: (string|number)}} source The widget source.
|
|
121
|
+
* @param {{exactSchema: (boolean|undefined)}} [options] `exactSchema: true` skips the title-to-slug step.
|
|
105
122
|
* @return {string} The type slug to use for store CRUD calls.
|
|
106
123
|
*/
|
|
107
|
-
export function resolveObjectOpType(store, source) {
|
|
124
|
+
export function resolveObjectOpType(store, source, options = {}) {
|
|
108
125
|
const register = String(source.register)
|
|
109
|
-
const schema = String(schemaRefSlug(source.schema))
|
|
126
|
+
const schema = options.exactSchema === true ? String(source.schema) : String(schemaRefSlug(source.schema))
|
|
127
|
+
const written = typeof source.schema === 'string'
|
|
128
|
+
? source.schema.substring(source.schema.lastIndexOf('/') + 1)
|
|
129
|
+
: ''
|
|
110
130
|
const registry = store.objectTypeRegistry || {}
|
|
111
131
|
for (const [slug, config] of Object.entries(registry)) {
|
|
112
132
|
if (!config) {
|
|
@@ -120,6 +140,9 @@ export function resolveObjectOpType(store, source) {
|
|
|
120
140
|
const slug = `${register}/${schema}`
|
|
121
141
|
if (!registry[slug]) {
|
|
122
142
|
store.registerObjectType(slug, schema, register)
|
|
143
|
+
if (written !== '' && written !== schema && typeof store.setSchemaFallback === 'function') {
|
|
144
|
+
store.setSchemaFallback(slug, written)
|
|
145
|
+
}
|
|
123
146
|
}
|
|
124
147
|
return slug
|
|
125
148
|
}
|
|
@@ -15,13 +15,19 @@
|
|
|
15
15
|
* Parameter shapes, as OpenRegister reads them:
|
|
16
16
|
* - equality and "any of": `key=value`, `key[]=a&key[]=b`
|
|
17
17
|
* - ranges: `key[gte]=from`, `key[lte]=to`
|
|
18
|
+
* - contains (text): `key[like]=term`, case-insensitive (openregister#4430);
|
|
19
|
+
* the server escapes `%`, `_` and `\`, so the raw term goes out as typed
|
|
18
20
|
*
|
|
19
21
|
* @module utils/columnFilters
|
|
20
22
|
* @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md
|
|
23
|
+
* @spec openspec/changes/header-filter-contains/specs/cn-data-table/spec.md#requirement-a-text-header-filter-matches-on-contains
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { schemaRefSlug } from './schemaRefSlug.js'
|
|
24
27
|
|
|
28
|
+
/** OpenRegister's case-insensitive contains operator, used by the text filter. */
|
|
29
|
+
export const CONTAINS_OPERATOR = 'like'
|
|
30
|
+
|
|
25
31
|
/** Column keys that mark a value computed on the client, never stored. */
|
|
26
32
|
const COMPUTED_MARKERS = ['aggregate', 'compute', 'computed', 'virtual']
|
|
27
33
|
|
|
@@ -160,7 +166,7 @@ function referenceTarget(column, prop) {
|
|
|
160
166
|
*
|
|
161
167
|
* Kinds: `enum` (checkbox list), `boolean` (yes, no, any), `reference`
|
|
162
168
|
* (searchable list of the referenced objects), `number` and `date` (from and
|
|
163
|
-
* to), and `string` (
|
|
169
|
+
* to), and `string` (contains, through OpenRegister's `[like]`). `filterable: false` on the column turns it
|
|
164
170
|
* off. A column with no schema property behind it only filters when it
|
|
165
171
|
* carries an enum hint (`enum`, or a badge widget's colour map), because the
|
|
166
172
|
* server cannot filter on a value it does not store.
|
|
@@ -234,9 +240,25 @@ export function columnFilterParamKeys(def) {
|
|
|
234
240
|
if (def.kind === 'number' || def.kind === 'date') {
|
|
235
241
|
return [`${def.key}[gte]`, `${def.key}[lte]`]
|
|
236
242
|
}
|
|
243
|
+
if (def.kind === 'string') {
|
|
244
|
+
return [containsKey(def)]
|
|
245
|
+
}
|
|
237
246
|
return [def.key]
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The parameter a text filter writes: `{key}[like]`. An exact `key=value`
|
|
251
|
+
* from the facet sidebar or a fixed filter is a different key, so the header
|
|
252
|
+
* neither shows nor clears it.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} def A filter definition of kind `string`.
|
|
255
|
+
* @return {string} The parameter key.
|
|
256
|
+
* @spec openspec/changes/header-filter-contains/specs/cn-data-table/spec.md#requirement-a-text-header-filter-matches-on-contains
|
|
257
|
+
*/
|
|
258
|
+
function containsKey(def) {
|
|
259
|
+
return `${def.key}[${CONTAINS_OPERATOR}]`
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
/**
|
|
241
263
|
* A column filter's current state, read from the active-filter map.
|
|
242
264
|
*
|
|
@@ -262,6 +284,9 @@ export function columnFilterState(def, activeFilters) {
|
|
|
262
284
|
to: def.kind === 'date' ? to.slice(0, 10) : to,
|
|
263
285
|
}
|
|
264
286
|
}
|
|
287
|
+
if (def.kind === 'string') {
|
|
288
|
+
return { value: asList(filters[containsKey(def)])[0] || '' }
|
|
289
|
+
}
|
|
265
290
|
return { value: asList(filters[def.key])[0] || '' }
|
|
266
291
|
}
|
|
267
292
|
|
|
@@ -309,8 +334,10 @@ export function columnFilterParams(def, state) {
|
|
|
309
334
|
if (def.kind === 'boolean') {
|
|
310
335
|
return { [def.key]: (s.value === 'true' || s.value === 'false') ? [s.value] : [] }
|
|
311
336
|
}
|
|
337
|
+
// Text: the raw term under `[like]`. No wildcards: OpenRegister wraps
|
|
338
|
+
// the term itself and escapes `%`, `_` and `\` in it.
|
|
312
339
|
const value = s.value === undefined || s.value === null ? '' : String(s.value).trim()
|
|
313
|
-
return { [def
|
|
340
|
+
return { [containsKey(def)]: value === '' ? [] : [value] }
|
|
314
341
|
}
|
|
315
342
|
|
|
316
343
|
/**
|
package/src/utils/schema.js
CHANGED
|
@@ -367,7 +367,7 @@ function isUserProp(prop) {
|
|
|
367
367
|
if (!prop || typeof prop !== 'object') {
|
|
368
368
|
return false
|
|
369
369
|
}
|
|
370
|
-
if (prop.referenceType === 'nextcloud-user') {
|
|
370
|
+
if (prop.referenceType === 'nextcloud-user' || prop.widget === 'user') {
|
|
371
371
|
return true
|
|
372
372
|
}
|
|
373
373
|
const format = prop.format || ''
|
|
@@ -389,7 +389,44 @@ function isGroupProp(prop) {
|
|
|
389
389
|
if (!prop || typeof prop !== 'object') {
|
|
390
390
|
return false
|
|
391
391
|
}
|
|
392
|
-
return prop.referenceType === 'nextcloud-group' || prop.format === 'nc-group'
|
|
392
|
+
return prop.referenceType === 'nextcloud-group' || prop.format === 'nc-group' || prop.widget === 'group'
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Make a manifest field override that names a picker widget behave exactly
|
|
397
|
+
* like the schema format that triggers it.
|
|
398
|
+
*
|
|
399
|
+
* OpenRegister refuses formats it does not know (`nc-group`, `language`,
|
|
400
|
+
* `timezone`) at import, so an app cannot always put them in its schema. It
|
|
401
|
+
* declares `fieldOverrides.<key>.widget: 'group' | 'language' | 'timezone'`
|
|
402
|
+
* (or `'user'`) instead. The widget alone is not enough for the user and
|
|
403
|
+
* group pickers, which CnFormDialog recognises by `userPicker` /
|
|
404
|
+
* `groupPicker`, so set those here. An override may also carry its own
|
|
405
|
+
* `x-default` (`current-language`, `current-timezone`), which becomes the
|
|
406
|
+
* field's `defaultToken` just as the schema key does.
|
|
407
|
+
*
|
|
408
|
+
* Mutates `field` in place.
|
|
409
|
+
*
|
|
410
|
+
* @spec openspec/changes/review-round-two/specs/schema-utilities/spec.md
|
|
411
|
+
* @param {object} field The field descriptor, overrides already merged in.
|
|
412
|
+
* @param {object} override The raw override for this key.
|
|
413
|
+
* @return {void}
|
|
414
|
+
*/
|
|
415
|
+
function applyPickerOverride(field, override) {
|
|
416
|
+
if (!override || typeof override !== 'object') {
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
const widget = override.widget
|
|
420
|
+
if (widget === 'group' || widget === 'group-multiselect') {
|
|
421
|
+
field.groupPicker = { multiple: widget === 'group-multiselect' }
|
|
422
|
+
field.userPicker = null
|
|
423
|
+
} else if (widget === 'user' || widget === 'user-multiselect') {
|
|
424
|
+
field.userPicker = { multiple: widget === 'user-multiselect' }
|
|
425
|
+
field.groupPicker = null
|
|
426
|
+
}
|
|
427
|
+
if (typeof override['x-default'] === 'string' && override['x-default'] !== '') {
|
|
428
|
+
field.defaultToken = override['x-default']
|
|
429
|
+
}
|
|
393
430
|
}
|
|
394
431
|
|
|
395
432
|
/**
|
|
@@ -844,6 +881,7 @@ export function fieldsFromSchema(schema, options = {}) {
|
|
|
844
881
|
// Apply per-field overrides
|
|
845
882
|
if (overrides[key]) {
|
|
846
883
|
Object.assign(field, overrides[key])
|
|
884
|
+
applyPickerOverride(field, overrides[key])
|
|
847
885
|
}
|
|
848
886
|
|
|
849
887
|
return field
|