@conduction/nextcloud-vue 2.117.0 → 2.118.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.
Files changed (97) hide show
  1. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js +2 -1
  2. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +6 -6
  5. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  6. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js +2 -1
  7. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js.map +1 -1
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +18 -0
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +14 -8
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  14. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -1
  15. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  17. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +9 -4
  18. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  19. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnMailTab/CnMailTab.vue2.js +2 -1
  21. package/dist/esm/components/CnMailTab/CnMailTab.vue2.js.map +1 -1
  22. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  23. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +71 -0
  24. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  25. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +25 -9
  26. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  27. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  28. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +11 -2
  29. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  30. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  31. package/dist/esm/components/CnSearchPage/CnSearchPage.vue.js +1 -1
  32. package/dist/esm/components/CnSearchPage/CnSearchPage.vue2.js +161 -5
  33. package/dist/esm/components/CnSearchPage/CnSearchPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnSearchPage/CnSearchPage.vue3.js +9 -9
  35. package/dist/esm/components/CnSearchPage/CnSearchPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  37. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +35 -5
  38. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  40. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js +9 -0
  41. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js.map +1 -0
  42. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js +435 -0
  43. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js.map +1 -0
  44. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js +300 -0
  45. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js.map +1 -0
  46. package/dist/esm/composables/useEndpointSource.js +1 -36
  47. package/dist/esm/composables/useEndpointSource.js.map +1 -1
  48. package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
  49. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +57 -336
  50. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
  51. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +18 -155
  52. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
  53. package/dist/esm/index.js +2 -0
  54. package/dist/esm/index.js.map +1 -1
  55. package/dist/esm/l10n/en.json.js +2 -1
  56. package/dist/esm/l10n/en.json.js.map +1 -1
  57. package/dist/esm/l10n/nl.json.js +2 -1
  58. package/dist/esm/l10n/nl.json.js.map +1 -1
  59. package/dist/esm/library-style.js +1 -1
  60. package/dist/esm/nextcloud-vue.css +308 -229
  61. package/dist/esm/utils/actionsDispatcher.js +3 -2
  62. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  63. package/dist/esm/utils/interpolateUrlTokens.js +43 -0
  64. package/dist/esm/utils/interpolateUrlTokens.js.map +1 -0
  65. package/dist/esm/utils/labelsByField.js +72 -0
  66. package/dist/esm/utils/labelsByField.js.map +1 -0
  67. package/dist/esm/utils/listCreateAction.js +91 -0
  68. package/dist/esm/utils/listCreateAction.js.map +1 -0
  69. package/dist/esm/utils/transitionInputs.js +493 -0
  70. package/dist/esm/utils/transitionInputs.js.map +1 -0
  71. package/dist/esm/utils/visibleWhen.js +9 -2
  72. package/dist/esm/utils/visibleWhen.js.map +1 -1
  73. package/dist/nextcloud-vue.cjs.js +6690 -5404
  74. package/dist/nextcloud-vue.cjs.js.map +2 -2
  75. package/dist/nextcloud-vue.css +308 -229
  76. package/l10n/en.json +2 -1
  77. package/l10n/nl.json +2 -1
  78. package/package.json +1 -1
  79. package/src/components/CnActionsBar/CnActionsBar.vue +6 -6
  80. package/src/components/CnDashboardPage/CnDashboardPage.vue +27 -5
  81. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +11 -1
  82. package/src/components/CnIndexPage/CnIndexPage.vue +9 -4
  83. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +86 -0
  84. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +11 -2
  85. package/src/components/CnSearchPage/CnSearchPage.vue +170 -14
  86. package/src/components/CnTabsWidget/CnTabsWidget.vue +35 -5
  87. package/src/components/CnTransitionInputFields/CnTransitionInputFields.vue +750 -0
  88. package/src/components/CnTransitionInputFields/index.js +11 -0
  89. package/src/components/index.js +1 -0
  90. package/src/composables/useEndpointSource.js +3 -36
  91. package/src/dialogs/CnTransitionInputDialog.vue +73 -465
  92. package/src/index.js +2 -0
  93. package/src/utils/interpolateUrlTokens.js +40 -0
  94. package/src/utils/labelsByField.js +69 -0
  95. package/src/utils/listCreateAction.js +87 -0
  96. package/src/utils/transitionInputs.js +489 -0
  97. package/src/utils/visibleWhen.js +9 -2
package/l10n/en.json CHANGED
@@ -1974,7 +1974,8 @@
1974
1974
  "Other open tasks": "Other open tasks",
1975
1975
  "Task {title} finished": "Task {title} finished",
1976
1976
  "Task {title} is yours": "Task {title} is yours",
1977
- "Fill in {field} before completing this task": "Fill in {field} before completing this task"
1977
+ "Fill in {field} before completing this task": "Fill in {field} before completing this task",
1978
+ "Search in this list": "Search in this list"
1978
1979
  },
1979
1980
  "plurals": {
1980
1981
  "{count} member": [
package/l10n/nl.json CHANGED
@@ -2224,7 +2224,8 @@
2224
2224
  "{field}, up to": "{field}, tot en met",
2225
2225
  "Withheld": "Afgeschermd",
2226
2226
  "This value is not shown.": "Deze waarde wordt niet getoond.",
2227
- "You may not open the linked object, so its value is not shown.": "U mag het gekoppelde object niet openen, daarom wordt de waarde niet getoond."
2227
+ "You may not open the linked object, so its value is not shown.": "U mag het gekoppelde object niet openen, daarom wordt de waarde niet getoond.",
2228
+ "Search in this list": "Zoeken in deze lijst"
2228
2229
  },
2229
2230
  "plurals": {
2230
2231
  "{count} member": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.117.0",
3
+ "version": "2.118.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -27,9 +27,9 @@
27
27
  <input
28
28
  type="search"
29
29
  class="cn-actions-bar__search-input"
30
- :placeholder="searchPlaceholder || t('nextcloud-vue', 'Search…')"
30
+ :placeholder="searchPlaceholder || t('nextcloud-vue', 'Search in this list')"
31
31
  :value="searchValue"
32
- :aria-label="searchPlaceholder || t('nextcloud-vue', 'Search')"
32
+ :aria-label="searchPlaceholder || t('nextcloud-vue', 'Search in this list')"
33
33
  @input="onSearchInput">
34
34
  </div>
35
35
  <!--
@@ -117,9 +117,9 @@
117
117
  <input
118
118
  type="search"
119
119
  class="cn-actions-bar__search-input"
120
- :placeholder="searchPlaceholder || t('nextcloud-vue', 'Search…')"
120
+ :placeholder="searchPlaceholder || t('nextcloud-vue', 'Search in this list')"
121
121
  :value="searchValue"
122
- :aria-label="searchPlaceholder || t('nextcloud-vue', 'Search')"
122
+ :aria-label="searchPlaceholder || t('nextcloud-vue', 'Search in this list')"
123
123
  @input="onSearchInput">
124
124
  </div>
125
125
  <span v-else-if="showCount && hasTotal" class="cn-actions-bar__count">
@@ -392,9 +392,9 @@
392
392
  <input
393
393
  type="search"
394
394
  class="cn-actions-bar__search-input"
395
- :placeholder="searchPlaceholder || t('nextcloud-vue', 'Search…')"
395
+ :placeholder="searchPlaceholder || t('nextcloud-vue', 'Search in this list')"
396
396
  :value="searchValue"
397
- :aria-label="searchPlaceholder || t('nextcloud-vue', 'Search')"
397
+ :aria-label="searchPlaceholder || t('nextcloud-vue', 'Search in this list')"
398
398
  @input="onSearchInput">
399
399
  </div>
400
400
  <span v-else-if="showCount && hasTotal && !searchInToolbar" class="cn-actions-bar__count">
@@ -120,6 +120,10 @@
120
120
  <!-- @slot actions Back-compat alias for `#header-actions`. Prefer `#header-actions` in new code. -->
121
121
  <slot name="actions" />
122
122
  </template>
123
+ <CnActionButtons
124
+ v-else-if="boardSecondaryHeaderActions.length"
125
+ :actions="boardSecondaryHeaderActions"
126
+ data-testid="cn-dashboard-page-header-actions-secondary" />
123
127
  <NcButton
124
128
  v-if="allowEdit"
125
129
  :variant="isEditing ? 'primary' : 'secondary'"
@@ -179,14 +183,14 @@
179
183
  <slot name="action-items" />
180
184
  </template>
181
185
  </CnActionsMenu>
182
- <!-- Board look: the row reads Edit layout, Actions, the buildiq
183
- square, then the manifest's header actions with the primary
184
- rightmost (screens-dashboard-parity). -->
186
+ <!-- Board look: the row reads the secondary header actions, Edit
187
+ layout, Actions, the buildiq square, then the primary header
188
+ actions rightmost (screens-dashboard-parity). -->
185
189
  <template v-if="isBoardLook">
186
190
  <CnBuildiqEditButton />
187
191
  <CnActionButtons
188
- v-if="headerActions && headerActions.length"
189
- :actions="linkedHeaderActions"
192
+ v-if="boardPrimaryHeaderActions.length"
193
+ :actions="boardPrimaryHeaderActions"
190
194
  data-testid="cn-dashboard-page-header-actions" />
191
195
  <slot name="header-actions" />
192
196
  <slot name="actions" />
@@ -2135,6 +2139,24 @@ export default {
2135
2139
  return (this.headerActions || []).map(headerActionAsLink)
2136
2140
  },
2137
2141
 
2142
+ /**
2143
+ * Under the board look the header reads the secondary actions, the
2144
+ * edit toggle and Actions menu, the buildiq square, then the primary
2145
+ * actions, as on an index page (PtDashboard, DqDashboard).
2146
+ *
2147
+ * @return {Array<object>} The header actions that are not `variant: "primary"`.
2148
+ */
2149
+ boardSecondaryHeaderActions() {
2150
+ return this.linkedHeaderActions.filter((entry) => entry?.variant !== 'primary')
2151
+ },
2152
+
2153
+ /**
2154
+ * @return {Array<object>} The `variant: "primary"` header actions, drawn after the buildiq square.
2155
+ */
2156
+ boardPrimaryHeaderActions() {
2157
+ return this.linkedHeaderActions.filter((entry) => entry?.variant === 'primary')
2158
+ },
2159
+
2138
2160
  /**
2139
2161
  * The greeting for the time of day, with the reader's first (or
2140
2162
  * full) name when known, or '' when `greeting` is off.
@@ -348,6 +348,7 @@ import { resolveHeaderLink, widgetHeaderLink } from '../../utils/contentHeaderLi
348
348
  import { pickDeclaredProps } from '../../utils/declaredProps.js'
349
349
  import { unwrapAppConfig } from '../../utils/formatMetric.js'
350
350
  import { isActivityWidget } from '../../utils/headerMeta.js'
351
+ import { createActionState, listCreateAction } from '../../utils/listCreateAction.js'
351
352
  import { PANEL_ACTION_SINK } from '../../utils/panelActions.js'
352
353
  import { resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
353
354
  import {
@@ -1254,7 +1255,12 @@ export default {
1254
1255
  if (!['object-list', 'table'].includes(this.widget?.type)) {
1255
1256
  return false
1256
1257
  }
1257
- return this.content.allowCreate !== false
1258
+ if (this.content.allowCreate === false) {
1259
+ return false
1260
+ }
1261
+ // A declared `createAction` shows only where its visibleWhen holds,
1262
+ // the same answer the list gives for its own Add buttons.
1263
+ return createActionState(this.content, this.object).visible
1258
1264
  },
1259
1265
 
1260
1266
  /**
@@ -1285,6 +1291,10 @@ export default {
1285
1291
 
1286
1292
  /** @return {string} Label for the catalog Add action. */
1287
1293
  addLabel() {
1294
+ const action = listCreateAction(this.content)
1295
+ if (action && typeof action.label === 'string' && action.label !== '') {
1296
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(action.label) : action.label
1297
+ }
1288
1298
  return t('nextcloud-vue', 'Add')
1289
1299
  },
1290
1300
 
@@ -1375,6 +1375,7 @@ import { fetchGroupedCounts } from '../../utils/fetchAggregate.js'
1375
1375
  import { fetchFilterCounts, fetchListTotal } from '../../utils/fetchFilterCounts.js'
1376
1376
  import { unwrapAppConfig } from '../../utils/formatMetric.js'
1377
1377
  import { buildExportUrl } from '../../utils/indexExportHelpers.js'
1378
+ import { labelsByField } from '../../utils/labelsByField.js'
1378
1379
  import { lensUnavailableText } from '../../utils/lensAvailability.js'
1379
1380
  import { isModifiedClick, openRowTarget } from '../../utils/linkNavigation.js'
1380
1381
  import { resolveClaimedTeams, resolveClaimTokens, splitViewsIntoTabs, viewAsTab, viewIdOf } from '../../utils/listLenses.js'
@@ -2208,14 +2209,16 @@ export default {
2208
2209
  /**
2209
2210
  * Quick filters made from the values a field holds in this list
2210
2211
  * (manifest `config.quickFiltersFrom`): `{ field, register?, schema?,
2211
- * labelField?, allLabel? }`. The page asks OpenRegister for the
2212
+ * valueField?, labelField?, allLabel? }`. The page asks OpenRegister for the
2212
2213
  * distinct values of `field` with their counts (within `filter`) and
2213
2214
  * draws "All" plus one tab per value, the most frequent first, each
2214
2215
  * with its count. With `register` and `schema` the values are ids of
2215
2216
  * records of that schema and each tab shows the record's `labelField`
2216
- * (else its title or name). Ignored when `quickFilters` is set.
2217
+ * (else its title or name). With `valueField` as well, the values are
2218
+ * matched on that field of those records instead of their id (a slug).
2219
+ * Ignored when `quickFilters` is set.
2217
2220
  *
2218
- * @type {{field: string, register?: string, schema?: string, labelField?: string, allLabel?: string}|null}
2221
+ * @type {{field: string, register?: string, schema?: string, valueField?: string, labelField?: string, allLabel?: string}|null}
2219
2222
  * @spec openspec/changes/index-quick-filters-from/specs/index-page/spec.md#requirement-quick-filters-can-come-from-a-field-s-values
2220
2223
  */
2221
2224
  quickFiltersFrom: {
@@ -8727,7 +8730,9 @@ export default {
8727
8730
  groups = []
8728
8731
  }
8729
8732
  let labels = {}
8730
- if (from.register && from.schema && groups.length > 0) {
8733
+ if (from.register && from.schema && from.valueField && groups.length > 0) {
8734
+ labels = await labelsByField({ register: from.register, schema: from.schema, field: from.valueField, values: groups.map((g) => g.key), labelField: from.labelField || '' }).catch(() => ({}))
8735
+ } else if (from.register && from.schema && groups.length > 0) {
8731
8736
  if (!this._quickFilterLabelResolver) {
8732
8737
  this._quickFilterLabelResolver = createRefLabelResolver(() => {
8733
8738
  try {
@@ -243,12 +243,27 @@
243
243
  <!-- `content.footnote`: a line under the table ("Awb 4:17 en 4:18"),
244
244
  through the host translate. Not while the list waits for its
245
245
  context or failed to load. -->
246
+ <!-- `content.summary`: counts over the loaded rows ("(1/2 completed)"). -->
247
+ <p
248
+ v-if="resolvedSummary && !waitingForContext && !error && rows.length > 0"
249
+ class="cn-object-list-widget__summary"
250
+ data-testid="cn-object-list-widget-summary">
251
+ {{ resolvedSummary }}
252
+ </p>
246
253
  <p
247
254
  v-if="resolvedFootnote && !waitingForContext && !error"
248
255
  class="cn-object-list-widget__footnote"
249
256
  data-testid="cn-object-list-widget-footnote">
250
257
  {{ resolvedFootnote }}
251
258
  </p>
259
+ <!-- Why the declared create action is withheld on this record
260
+ (`content.createAction.hiddenNote`). -->
261
+ <p
262
+ v-if="createHiddenNote && !waitingForContext && !error"
263
+ class="cn-object-list-widget__hidden-note"
264
+ data-testid="cn-object-list-widget-hidden-note">
265
+ {{ createHiddenNote }}
266
+ </p>
252
267
  <!-- Create affordance (ADR-062): every collection carries its Add at
253
268
  the bottom of the widget; the host card's Actions menu calls the
254
269
  same openCreate() through the public method. Suppressed while the
@@ -317,11 +332,13 @@ import { useWriteFeedback } from '../../composables/useWriteFeedback.js'
317
332
  import { actionLink, dispatchAction, hasActionTargetTokens } from '../../utils/actionsDispatcher.js'
318
333
  import { buildExportUrl } from '../../utils/indexExportHelpers.js'
319
334
  import { followLinkClick, openRowTarget, resolveHref } from '../../utils/linkNavigation.js'
335
+ import { createActionState } from '../../utils/listCreateAction.js'
320
336
  import { objectDisplayName, objectFieldValue } from '../../utils/objectName.js'
321
337
  import { moveIndex, renumberRows, rowIdOf } from '../../utils/reorderRows.js'
322
338
  import { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
323
339
  import { markNewTabHandled } from '../../utils/rowAuxClick.js'
324
340
  import { changedKeys } from '../../utils/saveEdited.js'
341
+ import { evaluateVisibleWhenLocal } from '../../utils/visibleWhen.js'
325
342
  import { dispatchObjectCreated } from '../../utils/walkthroughSignals.js'
326
343
  import { getCurrentUserGroups } from '../../utils/widgetVisibility.js'
327
344
  import { CnRowActions } from '../CnRowActions/index.js'
@@ -447,6 +464,17 @@ export default {
447
464
  * those carrying a selected value. Client-side over the fetched page,
448
465
  * like the rest of this widget's row set.
449
466
  *
467
+ * `createAction` (one action of the `rowActions` shape) replaces the
468
+ * list's own create form: every Add, here and in the host card's menu,
469
+ * runs it instead, labelled with its `label`. Its `visibleWhen` is
470
+ * evaluated against the page record, and while it fails the Add is
471
+ * hidden and `hiddenNote` (a string, or `[{ when, text }]` where the
472
+ * first holding entry wins) is shown in its place.
473
+ *
474
+ * `summary` (`{ text, counts }`) is a line under the rows: `{total}` is
475
+ * the number of loaded rows and each `counts` key the number of loaded
476
+ * rows its `visibleWhen`-shaped condition holds for.
477
+ *
450
478
  * `hideHeader: true` drops the table's column header row (CnDataTable's
451
479
  * `hideHeader`), for a short list that reads as rows, not as a table.
452
480
  *
@@ -1091,9 +1119,55 @@ export default {
1091
1119
  /** Whether the create affordance renders (on by default; `content.allowCreate: false` opts out). */
1092
1120
  allowCreate() {
1093
1121
  const c = this.content || {}
1122
+ if (this.createState.action) {
1123
+ return c.allowCreate !== false && this.createState.visible
1124
+ }
1094
1125
  return c.allowCreate !== false && Boolean(c.register) && Boolean(c.schema)
1095
1126
  },
1096
1127
 
1128
+ /**
1129
+ * The declared `content.createAction` against the page record: the
1130
+ * action, whether it shows, and the untranslated note when it does not.
1131
+ *
1132
+ * @return {{action: (object|null), visible: boolean, note: string}}
1133
+ */
1134
+ createState() {
1135
+ return createActionState(this.content, this.objectCtx ? this.objectCtx.object : null)
1136
+ },
1137
+
1138
+ /**
1139
+ * Why the create action is withheld, through the host translate, or ''.
1140
+ *
1141
+ * @return {string}
1142
+ */
1143
+ createHiddenNote() {
1144
+ const note = this.createState.note
1145
+ const fn = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
1146
+ return note ? fn(note) : ''
1147
+ },
1148
+
1149
+ /**
1150
+ * `content.summary` filled in: `{total}` is the number of loaded rows
1151
+ * and every `counts` key the number of loaded rows its condition holds
1152
+ * for. The text goes through the host translate first, so a catalogue
1153
+ * entry keeps its placeholders.
1154
+ *
1155
+ * @return {string}
1156
+ */
1157
+ resolvedSummary() {
1158
+ const summary = this.content.summary
1159
+ if (!summary || typeof summary.text !== 'string' || summary.text === '') {
1160
+ return ''
1161
+ }
1162
+ const fn = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
1163
+ const values = { total: this.rows.length }
1164
+ const counts = summary.counts && typeof summary.counts === 'object' ? summary.counts : {}
1165
+ for (const [key, cond] of Object.entries(counts)) {
1166
+ values[key] = this.rows.filter((row) => evaluateVisibleWhenLocal(cond, row)).length
1167
+ }
1168
+ return fn(summary.text).replace(/\{(\w+)\}/g, (match, key) => (key in values ? String(values[key]) : match))
1169
+ },
1170
+
1097
1171
  /**
1098
1172
  * NcDialog size for the create dialog (`content.formSize`).
1099
1173
  *
@@ -1214,6 +1288,11 @@ export default {
1214
1288
  // `prompt`. It used to print raw, so a Dutch user read the English
1215
1289
  // manifest string. A label an app already translated has no key
1216
1290
  // of its own in the catalogue and comes back unchanged.
1291
+ const action = this.createState.action
1292
+ if (action && typeof action.label === 'string' && action.label !== '') {
1293
+ const fn = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
1294
+ return fn(action.label)
1295
+ }
1217
1296
  if (this.content.addLabel) {
1218
1297
  const fn = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
1219
1298
  return fn(this.content.addLabel)
@@ -1712,6 +1791,13 @@ export default {
1712
1791
 
1713
1792
  async openCreate() {
1714
1793
  const c = this.content || {}
1794
+ const { action, visible } = this.createState
1795
+ if (action) {
1796
+ if (visible) {
1797
+ this.dispatch(action)
1798
+ }
1799
+ return
1800
+ }
1715
1801
  if (!c.schema) {
1716
1802
  return
1717
1803
  }
@@ -374,7 +374,7 @@ export default {
374
374
 
375
375
  props: {
376
376
  /**
377
- * The list config: `{ register, schema, filter?, sort?, limit?, extend?,
377
+ * The list config: `{ register, schema, filter?, sort?, limit?, offset?, extend?,
378
378
  * presentation?, variant?, lead?, title?, meta?, text?, label?, pill?, trailing?, icon?,
379
379
  * rowRoute?, rowIdField?, headerLink?, headerNote?, emptyText?, errorText?,
380
380
  * valueLabels?, endpointSource?, bar? }`. `bar` (`{ valueField, maxField?,
@@ -603,10 +603,16 @@ export default {
603
603
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 5
604
604
  },
605
605
 
606
+ /** @return {number} How many rows of the list to skip first (`content.offset`), 0 by default. */
607
+ offset() {
608
+ const value = Number(this.content?.offset)
609
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
610
+ },
611
+
606
612
  /** @return {string} What a fetch depends on; a change fetches again. */
607
613
  requestKey() {
608
614
  const c = this.content || {}
609
- return JSON.stringify([c.register, c.schema, this.resolvedFilter, this.effectiveSort, this.limit, c.extend, this.waiting])
615
+ return JSON.stringify([c.register, c.schema, this.resolvedFilter, this.effectiveSort, this.limit, this.offset, c.extend, this.waiting])
610
616
  },
611
617
 
612
618
  /** @return {Array<object>} The rows ready to draw. */
@@ -1485,6 +1491,9 @@ export default {
1485
1491
  const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: c.register, schema: c.schema })
1486
1492
  const sort = this.effectiveSort
1487
1493
  const params = { _limit: this.limit }
1494
+ if (this.offset > 0) {
1495
+ params._offset = this.offset
1496
+ }
1488
1497
  // A rank sort (`sort.rank`) cannot be one `_order`: the list endpoint
1489
1498
  // orders a text field alphabetically. It is read group by group below.
1490
1499
  const rank = this.rankOf(sort)
@@ -21,13 +21,13 @@
21
21
 
22
22
  <div class="cn-search-page__body">
23
23
  <!-- Facet sidebar. -->
24
- <aside v-if="facets.length > 0" class="cn-search-page__facets" data-testid="cn-search-page-facets">
24
+ <aside v-if="shownFacets.length > 0" class="cn-search-page__facets" data-testid="cn-search-page-facets">
25
25
  <h3 class="cn-search-page__facets-title">
26
26
  {{ facetsTitle }}
27
27
  </h3>
28
- <div v-for="facet in facets" :key="facet.key" class="cn-search-page__facet">
28
+ <div v-for="facet in shownFacets" :key="facet.key" class="cn-search-page__facet">
29
29
  <h4 class="cn-search-page__facet-title">
30
- {{ facet.label || facet.key }}
30
+ {{ shown(facet.label) || facet.key }}
31
31
  </h4>
32
32
  <ul class="cn-search-page__facet-options">
33
33
  <li v-for="opt in facet.options"
@@ -39,7 +39,7 @@
39
39
  :type="facet.multiple ? 'checkbox' : 'radio'"
40
40
  :name="'facet-' + facet.key"
41
41
  @change="toggleFacet(facet.key, opt.value, $event.target.checked, facet.multiple)">
42
- <span>{{ opt.label || opt.value }}</span>
42
+ <span>{{ shown(opt.label) || opt.value }}</span>
43
43
  <small v-if="typeof opt.count === 'number'" class="cn-search-page__facet-count">{{ opt.count }}</small>
44
44
  </label>
45
45
  </li>
@@ -55,11 +55,11 @@
55
55
 
56
56
  <!-- Results pane. -->
57
57
  <section class="cn-search-page__results" data-testid="cn-search-page-results">
58
- <div v-if="loading" class="cn-search-page__loading">
58
+ <div v-if="shownLoading" class="cn-search-page__loading">
59
59
  {{ loadingLabel }}
60
60
  </div>
61
- <ol v-else-if="results.length > 0" class="cn-search-page__list">
62
- <li v-for="result in results"
61
+ <ol v-else-if="shownResults.length > 0" class="cn-search-page__list">
62
+ <li v-for="result in shownResults"
63
63
  :key="result.id || (result.schema + ':' + (result.title || ''))"
64
64
  class="cn-search-page__result"
65
65
  :data-result-id="result.id"
@@ -86,8 +86,8 @@
86
86
  {{ idleLabel }}
87
87
  </p>
88
88
 
89
- <div v-if="totalCount > 0 && totalCount > results.length" class="cn-search-page__more">
90
- {{ totalCountLabel(totalCount, results.length) }}
89
+ <div v-if="shownTotal > 0 && shownTotal > shownResults.length" class="cn-search-page__more">
90
+ {{ totalCountLabel(shownTotal, shownResults.length) }}
91
91
  </div>
92
92
  </section>
93
93
  </div>
@@ -192,6 +192,20 @@ export default {
192
192
  type: Function,
193
193
  default: (total, shown) => `Showing ${shown} of ${total} results.`,
194
194
  },
195
+
196
+ /**
197
+ * The lists the page searches itself (manifest `config.sources`). Each
198
+ * `{ id?, label, register, schema, route?, titleField?, snippetField?,
199
+ * limit? }`: one request per list with `_search`, results in the order
200
+ * of the lists, and the lists offered as a Type filter with their
201
+ * totals. `route` names the page a result opens, with its id. Empty
202
+ * keeps the page controlled: `results` and `facets` come from props.
203
+ *
204
+ * @type {Array<object>}
205
+ */
206
+ sources: { type: Array, default: () => [] },
207
+ /** The Type filter's heading when the page searches `sources`. */
208
+ typeLabel: { type: String, default: 'Type' },
195
209
  },
196
210
 
197
211
  emits: ['facets-change', 'query-change', 'result-click', 'search', 'update:query'],
@@ -199,18 +213,88 @@ export default {
199
213
  return {
200
214
  localQuery: this.query,
201
215
  hasSearched: false,
216
+ // What a `sources` search found, per list, and the filter it keeps.
217
+ sourceRows: {},
218
+ sourceTotals: {},
219
+ sourceFacets: {},
220
+ sourceLoading: false,
221
+ sourceRun: 0,
202
222
  }
203
223
  },
204
224
 
205
225
  computed: {
226
+ /** @return {boolean} Whether the page searches its own `sources`. */
227
+ searchesSources() {
228
+ return Array.isArray(this.sources) && this.sources.some((source) => source && source.register && source.schema)
229
+ },
230
+
231
+ /** @return {Array<object>} The lists searched, each with its key. */
232
+ sourceList() {
233
+ return (this.sources || [])
234
+ .filter((source) => source && source.register && source.schema)
235
+ .map((source) => ({ ...source, key: source.id || source.schema }))
236
+ },
237
+
238
+ /** @return {Record<string, Array<string>>} The active filter, the page's own when it searches `sources`. */
239
+ effectiveFacets() {
240
+ return this.searchesSources ? this.sourceFacets : (this.activeFacets || {})
241
+ },
242
+
243
+ /** @return {Array<object>} The facets shown: the Type filter of the lists, else the `facets` prop. */
244
+ shownFacets() {
245
+ if (!this.searchesSources) {
246
+ return this.facets
247
+ }
248
+ if (!this.hasSearched) {
249
+ return []
250
+ }
251
+ return [{
252
+ key: 'type',
253
+ label: this.typeLabel,
254
+ multiple: true,
255
+ options: this.sourceList.map((source) => ({
256
+ value: source.key,
257
+ label: source.label,
258
+ ...(typeof this.sourceTotals[source.key] === 'number' ? { count: this.sourceTotals[source.key] } : {}),
259
+ })),
260
+ }]
261
+ },
262
+
263
+ /** @return {Array<object>} The results shown. */
264
+ shownResults() {
265
+ if (!this.searchesSources) {
266
+ return this.results
267
+ }
268
+ return this.activeSources.flatMap((source) => this.sourceRows[source.key] || [])
269
+ },
270
+
271
+ /** @return {number} The total of the results shown. */
272
+ shownTotal() {
273
+ if (!this.searchesSources) {
274
+ return this.totalCount
275
+ }
276
+ return this.activeSources.reduce((sum, source) => sum + (this.sourceTotals[source.key] || 0), 0)
277
+ },
278
+
279
+ /** @return {boolean} Whether a search is running. */
280
+ shownLoading() {
281
+ return this.searchesSources ? this.sourceLoading : this.loading
282
+ },
283
+
284
+ /** @return {Array<object>} The lists the Type filter leaves in, all when none is picked. */
285
+ activeSources() {
286
+ const picked = Array.isArray(this.effectiveFacets.type) ? this.effectiveFacets.type : []
287
+ return picked.length === 0 ? this.sourceList : this.sourceList.filter((source) => picked.includes(source.key))
288
+ },
289
+
206
290
  /**
207
291
  * Whether any facet has at least one active value.
208
292
  *
209
293
  * @return {boolean}
210
294
  */
211
295
  hasActiveFacets() {
212
- for (const k of Object.keys(this.activeFacets || {})) {
213
- if (Array.isArray(this.activeFacets[k]) && this.activeFacets[k].length > 0) {
296
+ for (const k of Object.keys(this.effectiveFacets)) {
297
+ if (Array.isArray(this.effectiveFacets[k]) && this.effectiveFacets[k].length > 0) {
214
298
  return true
215
299
  }
216
300
  }
@@ -255,7 +339,7 @@ export default {
255
339
  * @return {boolean}
256
340
  */
257
341
  isFacetActive(key, value) {
258
- const arr = this.activeFacets[key]
342
+ const arr = this.effectiveFacets[key]
259
343
  return Array.isArray(arr) && arr.includes(value)
260
344
  },
261
345
 
@@ -269,7 +353,7 @@ export default {
269
353
  * @return {void}
270
354
  */
271
355
  toggleFacet(key, value, checked, multiple) {
272
- const next = { ...this.activeFacets }
356
+ const next = { ...this.effectiveFacets }
273
357
  if (!multiple) {
274
358
  next[key] = checked ? [value] : []
275
359
  } else {
@@ -300,6 +384,9 @@ export default {
300
384
  * @type {Record<string,Array<string>>}
301
385
  */
302
386
  this.$emit('facets-change', next)
387
+ if (this.searchesSources) {
388
+ this.sourceFacets = next
389
+ }
303
390
  this.emitSearch()
304
391
  },
305
392
 
@@ -340,7 +427,73 @@ export default {
340
427
  * Combines query + active facets.
341
428
  * @type {{ query: string, facets: Record<string,Array<string>> }}
342
429
  */
343
- this.$emit('search', { query: this.localQuery, facets: { ...this.activeFacets } })
430
+ this.$emit('search', { query: this.localQuery, facets: { ...this.effectiveFacets } })
431
+ if (this.searchesSources) {
432
+ this.searchSources()
433
+ }
434
+ },
435
+
436
+ /**
437
+ * Search the lists the Type filter leaves in, one request each, and
438
+ * keep what each found. A search started later wins over this one.
439
+ *
440
+ * @return {Promise<void>}
441
+ */
442
+ async searchSources() {
443
+ const run = this.sourceRun + 1
444
+ this.sourceRun = run
445
+ this.sourceLoading = true
446
+ const [{ default: axios }, { generateUrl }] = await Promise.all([
447
+ import('@nextcloud/axios'),
448
+ import('@nextcloud/router'),
449
+ ])
450
+ const found = await Promise.all(this.activeSources.map(async (source) => {
451
+ const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: source.register, schema: source.schema })
452
+ const limit = Number(source.limit) > 0 ? Math.floor(Number(source.limit)) : 10
453
+ try {
454
+ const response = await axios.get(url, { params: { _search: this.localQuery, _limit: limit } })
455
+ const data = response && response.data ? response.data : {}
456
+ const rows = Array.isArray(data.results) ? data.results : []
457
+ return { source, rows, total: typeof data.total === 'number' ? data.total : rows.length }
458
+ } catch (e) {
459
+ // eslint-disable-next-line no-console
460
+ console.warn('[CnSearchPage] could not search', source.schema, e)
461
+ return { source, rows: [], total: 0 }
462
+ }
463
+ }))
464
+ if (run !== this.sourceRun) {
465
+ return
466
+ }
467
+ const rows = { ...this.sourceRows }
468
+ const totals = { ...this.sourceTotals }
469
+ for (const { source, rows: list, total } of found) {
470
+ rows[source.key] = list.map((row) => this.resultOf(source, row))
471
+ totals[source.key] = total
472
+ }
473
+ this.sourceRows = rows
474
+ this.sourceTotals = totals
475
+ this.sourceLoading = false
476
+ },
477
+
478
+ /**
479
+ * One found object as a result row.
480
+ *
481
+ * @param {object} source The list it came from.
482
+ * @param {object} row The object.
483
+ * @return {object} `{ id, title, snippet, schema, route }`.
484
+ */
485
+ resultOf(source, row) {
486
+ const id = row && (row.id || row['@self']?.id)
487
+ const titleField = source.titleField || ''
488
+ const title = (titleField && row[titleField]) || row.title || row.name || id
489
+ const snippet = source.snippetField ? row[source.snippetField] : undefined
490
+ return {
491
+ id,
492
+ title: typeof title === 'string' ? title : String(title ?? ''),
493
+ ...(typeof snippet === 'string' && snippet !== '' ? { snippet } : {}),
494
+ schema: this.shown(source.label) || source.schema,
495
+ route: source.route || '',
496
+ }
344
497
  },
345
498
 
346
499
  /**
@@ -355,6 +508,9 @@ export default {
355
508
  * @type {object}
356
509
  */
357
510
  this.$emit('result-click', result)
511
+ if (this.searchesSources && result && result.route && result.id && this.$router) {
512
+ this.$router.push({ name: result.route, params: { id: result.id } })
513
+ }
358
514
  },
359
515
  },
360
516
  }