@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.
Files changed (119) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +19 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +101 -5
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +23 -7
  8. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  9. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  10. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  11. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  12. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +125 -4
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +439 -381
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  18. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  19. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  20. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +4 -4
  22. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  24. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +142 -26
  25. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  26. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +195 -124
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  29. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  30. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  31. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  33. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +84 -11
  34. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +3 -2
  36. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -1
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +335 -313
  40. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  41. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  42. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  43. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  44. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  45. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +5 -11
  46. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  47. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  48. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  49. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  50. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +22 -3
  51. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  52. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +10 -3
  53. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  54. package/dist/esm/composables/useDependencyCheck.js +44 -1
  55. package/dist/esm/composables/useDependencyCheck.js.map +1 -1
  56. package/dist/esm/composables/useListView.js +43 -1
  57. package/dist/esm/composables/useListView.js.map +1 -1
  58. package/dist/esm/composables/useSetupStatus.js +26 -3
  59. package/dist/esm/composables/useSetupStatus.js.map +1 -1
  60. package/dist/esm/l10n/en.json.js +7 -1
  61. package/dist/esm/l10n/en.json.js.map +1 -1
  62. package/dist/esm/l10n/nl.json.js +7 -1
  63. package/dist/esm/l10n/nl.json.js.map +1 -1
  64. package/dist/esm/mixins/pageViews.js +400 -0
  65. package/dist/esm/mixins/pageViews.js.map +1 -0
  66. package/dist/esm/nextcloud-vue.css +202 -2
  67. package/dist/esm/store/plugins/liveUpdates.js +19 -9
  68. package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
  69. package/dist/esm/store/useObjectStore.js +138 -42
  70. package/dist/esm/store/useObjectStore.js.map +1 -1
  71. package/dist/esm/utils/actionsDispatcher.js +25 -2
  72. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  73. package/dist/esm/utils/columnFilters.js +27 -3
  74. package/dist/esm/utils/columnFilters.js.map +1 -1
  75. package/dist/esm/utils/schema.js +40 -2
  76. package/dist/esm/utils/schema.js.map +1 -1
  77. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  78. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  79. package/dist/nextcloud-vue.cjs.js +2857 -1335
  80. package/dist/nextcloud-vue.cjs.js.map +2 -2
  81. package/dist/nextcloud-vue.css +202 -2
  82. package/l10n/en.json +7 -1
  83. package/l10n/nl.json +7 -1
  84. package/package.json +1 -1
  85. package/src/components/CnActionsBar/CnActionsBar.md +2 -1
  86. package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
  87. package/src/components/CnAppNav/CnAppNav.vue +150 -9
  88. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  89. package/src/components/CnDashboardPage/CnDashboardPage.vue +194 -10
  90. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  91. package/src/components/CnDataTable/CnDataTable.md +1 -1
  92. package/src/components/CnDataTable/CnDataTable.vue +4 -4
  93. package/src/components/CnDetailPage/CnDetailPage.md +5 -1
  94. package/src/components/CnDetailPage/CnDetailPage.vue +208 -11
  95. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  96. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  97. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +100 -13
  98. package/src/components/CnIndexPage/CnIndexPage.md +2 -1
  99. package/src/components/CnIndexPage/CnIndexPage.vue +47 -1
  100. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  101. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  102. package/src/components/CnSetupWizard/CnSetupWizard.vue +5 -11
  103. package/src/components/CnStagesWidget/CnStagesWidget.vue +9 -0
  104. package/src/components/CnStatWidget/CnStatWidget.vue +33 -5
  105. package/src/composables/useDependencyCheck.js +43 -0
  106. package/src/composables/useListView.js +43 -1
  107. package/src/composables/useSetupStatus.js +26 -3
  108. package/src/css/actions-bar.css +20 -2
  109. package/src/css/detail-page.css +27 -0
  110. package/src/css/kpi-card.css +47 -0
  111. package/src/mixins/pageViews.js +396 -0
  112. package/src/schemas/app-manifest-v2.schema.json +53 -4
  113. package/src/schemas/app-manifest.schema.json +1 -1
  114. package/src/store/plugins/liveUpdates.js +19 -9
  115. package/src/store/useObjectStore.js +137 -41
  116. package/src/utils/actionsDispatcher.js +25 -2
  117. package/src/utils/columnFilters.js +29 -2
  118. package/src/utils/schema.js +40 -2
  119. 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.46.0",
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. Do not combine with required: true; setup status would keep reporting the skipped step as unmet." },
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 'Settings'."
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 'Settings'."
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
- if (collectionInFlight.has(key)) {
441
- return collectionInFlight.get(key)
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 promise = originalFetchCollection(type, params).finally(() => {
444
- collectionInFlight.delete(key)
445
- })
446
- collectionInFlight.set(key, promise)
447
- return promise
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 url = this._buildUrlWithParams(type, fetchParams)
608
-
609
- const response = await fetch(url, {
610
- method: 'GET',
611
- headers: this._buildHeaders(),
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
- this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
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:`, this.errors[type])
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
- this.errors = {
644
- ...this.errors,
645
- [type]: error.name === 'TypeError'
646
- ? networkError(error)
647
- : genericError(error),
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 url = this._buildUrlWithParams(type, params)
679
- const response = await fetch(url, {
680
- method: 'GET',
681
- headers: this._buildHeaders(),
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 fetch(url, {
738
- method: 'GET',
739
- headers: this._buildHeaders(),
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 fetch(url, {
814
- method,
815
- headers: this._buildHeaders(),
816
- body: JSON.stringify(objectData),
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 url = this._buildUrl(type, id)
870
-
871
- const response = await fetch(url, {
872
- method: 'DELETE',
873
- headers: this._buildHeaders(),
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` (equals). `filterable: false` on the column turns it
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.key]: value === '' ? [] : [value] }
340
+ return { [containsKey(def)]: value === '' ? [] : [value] }
314
341
  }
315
342
 
316
343
  /**
@@ -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