@conduction/nextcloud-vue 2.56.0-beta.63 → 2.56.0-beta.65

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 (47) hide show
  1. package/dist/esm/components/CnActionDialog/CnActionDialog.vue.js +1 -1
  2. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js +115 -12
  3. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js +36 -9
  5. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js.map +1 -1
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +2 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +20 -0
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +68 -61
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +37 -12
  14. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  15. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  16. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
  17. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js +1 -1
  18. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
  19. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue.js +1 -1
  20. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue2.js +8 -1
  21. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue2.js.map +1 -1
  22. package/dist/esm/components/CnWorkQueue/CnWorkQueue.vue3.js.map +1 -1
  23. package/dist/esm/library-style.js +1 -1
  24. package/dist/esm/nextcloud-vue.css +131 -64
  25. package/dist/esm/utils/actionsDispatcher.js +5 -2
  26. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  27. package/dist/esm/utils/savedViewHelpers.js +40 -3
  28. package/dist/esm/utils/savedViewHelpers.js.map +1 -1
  29. package/dist/nextcloud-vue.cjs.js +479 -251
  30. package/dist/nextcloud-vue.cjs.js.map +2 -2
  31. package/dist/nextcloud-vue.css +131 -64
  32. package/package.json +1 -1
  33. package/src/components/CnActionDialog/CnActionDialog.vue +134 -13
  34. package/src/components/CnAppRoot/CnAppRoot.vue +2 -1
  35. package/src/components/CnDetailPage/CnDetailPage.vue +35 -11
  36. package/src/components/CnIndexPage/CnIndexPage.md +1 -1
  37. package/src/components/CnIndexPage/CnIndexPage.vue +37 -12
  38. package/src/components/CnIndexSidebar/CnIndexSidebar.md +1 -1
  39. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +2 -2
  40. package/src/components/CnWorkQueue/CnWorkQueue.vue +8 -1
  41. package/src/css/board.css +5 -2
  42. package/src/css/look-board-detail.css +19 -7
  43. package/src/css/look-board-index.css +14 -10
  44. package/src/css/look-board-reports.css +1 -1
  45. package/src/css/look-board.css +54 -9
  46. package/src/utils/actionsDispatcher.js +4 -1
  47. package/src/utils/savedViewHelpers.js +39 -2
@@ -5,7 +5,7 @@
5
5
  <template>
6
6
  <CnDialog
7
7
  :name="text(config.title) || t('nextcloud-vue', 'Confirm')"
8
- :eyebrow="text(config.eyebrow)"
8
+ :eyebrow="eyebrowText"
9
9
  :subtitle="text(config.subtitle)"
10
10
  width="form"
11
11
  :noClose="busy"
@@ -63,6 +63,24 @@
63
63
  </ul>
64
64
  </section>
65
65
 
66
+ <!-- Fields the action asks for, sent beside the reason. -->
67
+ <div v-for="field in fields"
68
+ :key="field.field"
69
+ class="cn-action-dialog__field"
70
+ :data-testid="`cn-action-dialog-field-${field.field}`">
71
+ <NcSelect v-if="field.type === 'select'"
72
+ :modelValue="selectedOption(field)"
73
+ :options="optionsOf(field)"
74
+ :inputLabel="text(field.label) || field.field"
75
+ :clearable="field.required === false"
76
+ @update:modelValue="(option) => pick(field, option)" />
77
+ <NcTextField v-else
78
+ :modelValue="values[field.field] || ''"
79
+ :type="field.type === 'datetime' ? 'datetime-local' : (field.type === 'date' ? 'date' : 'text')"
80
+ :label="text(field.label) || field.field"
81
+ @update:modelValue="(value) => setValue(field.field, value)" />
82
+ </div>
83
+
66
84
  <div v-if="config.reason" class="cn-action-dialog__field">
67
85
  <NcTextArea
68
86
  :id="reasonId"
@@ -116,7 +134,7 @@ import axios from '@nextcloud/axios'
116
134
  import { emit } from '@nextcloud/event-bus'
117
135
  import { translate as t } from '@nextcloud/l10n'
118
136
  import { generateUrl } from '@nextcloud/router'
119
- import { NcButton, NcNoteCard, NcTextArea } from '@nextcloud/vue'
137
+ import { NcButton, NcNoteCard, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue'
120
138
  import CheckCircle from 'vue-material-design-icons/CheckCircle.vue'
121
139
  import CircleOutline from 'vue-material-design-icons/CircleOutline.vue'
122
140
  import HelpCircleOutline from 'vue-material-design-icons/HelpCircleOutline.vue'
@@ -141,7 +159,8 @@ let reasonSeq = 0
141
159
  * note and Archive it.
142
160
  *
143
161
  * `config`:
144
- * - `title`, `eyebrow`, `subtitle`, `doneText`: texts, through the host translate.
162
+ * - `title`, `subtitle`, `doneText`: texts, through the host translate;
163
+ * `eyebrow` a template filled from the record ("Close off · {identifier}").
145
164
  * - `summary`: `{ source?, title, meta?, badge?, icon? }`: templates filled from
146
165
  * the record (`source` an endpointSource to read it from, else `object`).
147
166
  * - `facts`: `[{ title?, items: [{ label, value }] }]`, `value` a template.
@@ -149,8 +168,14 @@ let reasonSeq = 0
149
168
  * `{ id, met, label?, detail? }` read from `source`; `labels[id]` gives an
150
169
  * item's `{ label, detail?, unmetDetail? }`; the ids in `required` must be
151
170
  * met before the action can run. `met: null` reads as unknown.
152
- * - `permission`: `{ source, allowedPath, reasonPath? }`: the action runs only
153
- * while `allowedPath` reads true; `reasonPath` says why not.
171
+ * - `permission`: `{ source, listPath?, match?, allowedPath, reasonPath? }`:
172
+ * the action runs only while `allowedPath` reads true; `reasonPath` says
173
+ * why not. With `listPath` the paths read the entry of that list whose
174
+ * fields equal `match` (`{ act: "archive" }`).
175
+ * - `fields`: `[{ field, type?: "text"|"date"|"datetime"|"select", label?,
176
+ * required?, options?: [{ value, label, fills? }] }]`: inputs sent beside
177
+ * the reason, required by default; a select option's `fills` sets other
178
+ * fields that are still empty.
154
179
  * - `reason`: `{ label?, hint?, required?, field? }`: a text area; required by
155
180
  * default; sent as `field` (default `reason`).
156
181
  * - `note`: `{ variant?, title?, text }`: a read-only note.
@@ -163,7 +188,7 @@ let reasonSeq = 0
163
188
  export default {
164
189
  name: 'CnActionDialog',
165
190
 
166
- components: { CheckCircle, CircleOutline, CnDialog, CnIcon, HelpCircleOutline, NcButton, NcNoteCard, NcTextArea },
191
+ components: { CheckCircle, CircleOutline, CnDialog, CnIcon, HelpCircleOutline, NcButton, NcNoteCard, NcSelect, NcTextArea, NcTextField },
167
192
 
168
193
  inject: {
169
194
  /** The host app's translate (CnAppRoot `cnTranslate`). */
@@ -209,6 +234,7 @@ export default {
209
234
  checklistError: '',
210
235
  allowed: true,
211
236
  reason: '',
237
+ values: {},
212
238
  busy: false,
213
239
  refusal: '',
214
240
  done: false,
@@ -219,7 +245,21 @@ export default {
219
245
  computed: {
220
246
  /** @return {object} The token context. */
221
247
  tokenCtx() {
222
- return { objectId: this.objectId, object: this.record || {} }
248
+ return { objectId: this.recordId, object: this.record || {} }
249
+ },
250
+
251
+ /**
252
+ * The record's id: `objectId`, or the route's `id` when the prop is
253
+ * empty or a token the opener could not resolve.
254
+ *
255
+ * @return {string|number}
256
+ */
257
+ recordId() {
258
+ const given = this.objectId
259
+ if (given !== '' && given !== null && given !== undefined && !String(given).startsWith('@')) {
260
+ return given
261
+ }
262
+ return (this.$route && this.$route.params && this.$route.params.id) || ''
223
263
  },
224
264
 
225
265
  /** @return {object} The confirm config. */
@@ -305,7 +345,27 @@ export default {
305
345
  * @return {boolean}
306
346
  */
307
347
  canConfirm() {
308
- return !this.busy && this.allowed && this.requiredMet && (!this.reasonNeeded || this.reason.trim() !== '')
348
+ return !this.busy && this.allowed && this.requiredMet && this.fieldsFilled && (!this.reasonNeeded || this.reason.trim() !== '')
349
+ },
350
+
351
+ /** @return {string} The eyebrow, filled from the record once it is known. */
352
+ eyebrowText() {
353
+ return this.record ? this.fill(this.config.eyebrow) || this.text(this.config.eyebrow) : this.text(this.config.eyebrow)
354
+ },
355
+
356
+ /** @return {Array<object>} The fields with a name. */
357
+ fields() {
358
+ return (Array.isArray(this.config.fields) ? this.config.fields : []).filter((f) => f && f.field)
359
+ },
360
+
361
+ /**
362
+ * Whether every required field has a value.
363
+ *
364
+ * @spec openspec/changes/action-dialog-fields-and-acts/specs/action-dialog/spec.md#requirement-an-action-dialog-asks-for-fields-and-reads-a-permission-from-a-list
365
+ * @return {boolean}
366
+ */
367
+ fieldsFilled() {
368
+ return this.fields.every((f) => f.required === false || String(this.values[f.field] ?? '').trim() !== '')
309
369
  },
310
370
  },
311
371
 
@@ -380,6 +440,63 @@ export default {
380
440
  }
381
441
  },
382
442
 
443
+ /**
444
+ * The entry of a list whose fields equal `match`, or null.
445
+ *
446
+ * @param {unknown} list The list.
447
+ * @param {object} [match] The fields to match.
448
+ * @return {object|null}
449
+ */
450
+ matchIn(list, match) {
451
+ const wanted = Object.entries(match || {})
452
+ return (Array.isArray(list) ? list : []).find((entry) => entry && wanted.every(([key, value]) => String(entry[key]) === String(value))) || null
453
+ },
454
+
455
+ /**
456
+ * A select field's options, labels translated.
457
+ *
458
+ * @param {object} field The field.
459
+ * @return {Array<{id: string, label: string, fills?: object}>}
460
+ */
461
+ optionsOf(field) {
462
+ return (Array.isArray(field.options) ? field.options : []).map((o) => ({ id: String(o.value), label: this.text(o.label) || String(o.value), fills: o.fills }))
463
+ },
464
+
465
+ /**
466
+ * A select field's chosen option.
467
+ *
468
+ * @param {object} field The field.
469
+ * @return {object|null}
470
+ */
471
+ selectedOption(field) {
472
+ return this.optionsOf(field).find((o) => o.id === String(this.values[field.field] ?? '')) || null
473
+ },
474
+
475
+ /**
476
+ * Set a field's value.
477
+ *
478
+ * @param {string} name The field.
479
+ * @param {unknown} value The value.
480
+ */
481
+ setValue(name, value) {
482
+ this.values = { ...this.values, [name]: value === null || value === undefined ? '' : String(value) }
483
+ },
484
+
485
+ /**
486
+ * Choose a select option; its `fills` set the fields still empty.
487
+ *
488
+ * @param {object} field The select field.
489
+ * @param {{id: string, fills?: object}|null} option The option.
490
+ */
491
+ pick(field, option) {
492
+ this.setValue(field.field, option ? option.id : '')
493
+ for (const [name, value] of Object.entries((option && option.fills) || {})) {
494
+ if (String(this.values[name] ?? '').trim() === '') {
495
+ this.setValue(name, this.text(value) || value)
496
+ }
497
+ }
498
+ },
499
+
383
500
  /** @return {Promise<void>} */
384
501
  async loadPermission() {
385
502
  const def = this.config.permission
@@ -388,8 +505,9 @@ export default {
388
505
  }
389
506
  try {
390
507
  const data = await this.read(def.source)
391
- this.allowed = readPath(data, def.allowedPath || 'allowed') === true
392
- this.refusal = this.allowed || !def.reasonPath ? '' : String(readPath(data, def.reasonPath) || '')
508
+ const entry = def.listPath ? this.matchIn(readPath(data, def.listPath), def.match) : data
509
+ this.allowed = readPath(entry, def.allowedPath || 'allowed') === true
510
+ this.refusal = this.allowed || !def.reasonPath ? '' : String(readPath(entry, def.reasonPath) || '')
393
511
  } catch {
394
512
  this.allowed = false
395
513
  }
@@ -420,15 +538,18 @@ export default {
420
538
  if (this.config.reason) {
421
539
  body[this.config.reason.field || 'reason'] = reason
422
540
  }
541
+ for (const field of this.fields) {
542
+ body[field.field] = this.values[field.field] ?? ''
543
+ }
423
544
  const method = String(request.method || 'POST').toLowerCase()
424
545
  await axios[['post', 'put', 'patch', 'delete'].includes(method) ? method : 'post'](/^https?:\/\//i.test(url) ? url : generateUrl(url), body)
425
546
  } else if (this.confirmConfig.action && typeof this.cnDispatchAction === 'function') {
426
- await this.cnDispatchAction({ ...this.confirmConfig.action, args: [...(this.confirmConfig.action.args || []), { reason }] })
547
+ await this.cnDispatchAction({ ...this.confirmConfig.action, args: [...(this.confirmConfig.action.args || []), { ...this.values, reason }] })
427
548
  }
428
549
  this.done = true
429
550
  emit(PAGE_REFRESH, {})
430
- /** @event done The action ran. Payload: `{ reason }`. */
431
- this.$emit('done', { reason })
551
+ /** @event done The action ran. Payload: the field values and `reason`. */
552
+ this.$emit('done', { ...this.values, reason })
432
553
  } catch (failure) {
433
554
  const data = failure && failure.response && failure.response.data
434
555
  this.refusal = String((data && (data.message || data.error)) || t('nextcloud-vue', 'The action could not be completed.'))
@@ -758,6 +758,7 @@ import { installModalStack, uninstallModalStack } from '../../utils/modalStack.j
758
758
  import { OBJECTS_CHANGED_EVENT } from '../../utils/objectSignals.js'
759
759
  import { resolveBrand } from '../../utils/resolveBrand.js'
760
760
  import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
761
+ import { pageOffersSavedViews } from '../../utils/savedViewHelpers.js'
761
762
  import { normalizeSavedViewMenu, SAVED_VIEW_MENU_KEY, SAVED_VIEW_MENU_PREFIX, syncSavedViewMenu, toggleSavedViewMenuEntry, withSavedViewMenu } from '../../utils/savedViewMenu.js'
762
763
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
763
764
 
@@ -2400,7 +2401,7 @@ export default {
2400
2401
  */
2401
2402
  offersSavedViews() {
2402
2403
  const pages = this.manifest && Array.isArray(this.manifest.pages) ? this.manifest.pages : []
2403
- return pages.some((page) => page && page.config && page.config.allowSavedViews === true)
2404
+ return pages.some((page) => page && page.type === 'index' && pageOffersSavedViews(page.config))
2404
2405
  },
2405
2406
 
2406
2407
  /**
@@ -126,20 +126,13 @@
126
126
  </component>
127
127
  <!-- The one Follow control beside the title (manifest `config.follow`):
128
128
  shown when the object carries `@self.watching`. There is no star:
129
- a favourite is a follow with notifications off. -->
129
+ a favourite is a follow with notifications off. The board look
130
+ draws it on a row of its own under the breadcrumb. -->
130
131
  <span
131
- v-if="showFollowToggle"
132
+ v-if="showFollowToggle && !isBoard"
132
133
  class="cn-detail-page__interactions"
133
134
  data-testid="cn-detail-page-interactions">
134
- <CnFollowToggle
135
- :register="register"
136
- :schema="schema"
137
- :objectId="String(objectId)"
138
- :watching="resolvedSelf.watching === true"
139
- :notify="typeof resolvedSelf.watchNotify === 'boolean' ? resolvedSelf.watchNotify : null"
140
- :watcherCount="typeof resolvedSelf.watcherCount === 'number' ? resolvedSelf.watcherCount : null"
141
- :canManage="!!(resolvedSelf.can && resolvedSelf.can.manage === true)"
142
- :notifies="followNotifies" />
135
+ <CnFollowToggle v-bind="followToggleProps" />
143
136
  </span>
144
137
  </div>
145
138
  <!--
@@ -517,6 +510,17 @@
517
510
  :schema="currentSchema"
518
511
  :register="register" />
519
512
  </div>
513
+ <!-- Row 3 of the board look's header: the Follow control under the
514
+ breadcrumb, clear of the title row and its actions (DqZaak
515
+ "Volgen"). -->
516
+ <div
517
+ v-if="isBoard && showFollowToggle"
518
+ class="cn-detail-page__header-row3"
519
+ data-testid="cn-detail-page-header-row3">
520
+ <span class="cn-detail-page__interactions" data-testid="cn-detail-page-interactions">
521
+ <CnFollowToggle v-bind="followToggleProps" />
522
+ </span>
523
+ </div>
520
524
  <!-- A widget inside the header (manifest `config.headerWidget`), on
521
525
  a row of its own under the title and the actions: the stages
522
526
  bars of the board's case card. Drawn without a card of its own,
@@ -4319,6 +4323,26 @@ export default {
4319
4323
  && this.register !== '' && this.schema !== '' && this.objectId !== ''
4320
4324
  },
4321
4325
 
4326
+ /**
4327
+ * The props of the Follow control, wherever the header draws it.
4328
+ *
4329
+ * @return {object} The CnFollowToggle props.
4330
+ * @spec openspec/changes/board-follow-row/specs/board-follow-row/spec.md#requirement-the-board-follow-control-sits-under-the-breadcrumb
4331
+ */
4332
+ followToggleProps() {
4333
+ const self = this.resolvedSelf
4334
+ return {
4335
+ register: this.register,
4336
+ schema: this.schema,
4337
+ objectId: String(this.objectId),
4338
+ watching: self.watching === true,
4339
+ notify: typeof self.watchNotify === 'boolean' ? self.watchNotify : null,
4340
+ watcherCount: typeof self.watcherCount === 'number' ? self.watcherCount : null,
4341
+ canManage: !!(self.can && self.can.manage === true),
4342
+ notifies: this.followNotifies,
4343
+ }
4344
+ },
4345
+
4322
4346
  /**
4323
4347
  * The `_extend[]` values of the object read: the page's own, plus
4324
4348
  * `@self.can` while the Follow toggle can render (OpenRegister returns
@@ -368,7 +368,7 @@ export default {
368
368
  | `showMassImport` | Boolean | `true` | Whether to show the mass Import action |
369
369
  | `showMassCopy` | Boolean | `true` | Whether to show the mass Copy action |
370
370
  | `allowExport` | Boolean | `false` | Opt-in flag for the native Export menu (CSV/Excel) next to the Add button. Renders only when `true` AND the resolved schema is flagged `exportable: true`; navigates to OpenRegister's export leaf (`GET /apps/openregister/api/objects/{register}/{schema}/export`), passing `$route.query` through as filters. Distinct from `showMassExport`, which exports the fetched/selected rows via a blob download. |
371
- | `allowSavedViews` | Boolean | `false` | Opt-in flag for the saved-views control (saved-views-ui): a Views dropdown listing the user's OpenRegister saved-search views (`GET /apps/openregister/api/views`). Applying one writes its stored filters/search/sort into the route query (`_search` and `_order` reserved keys + plain filter keys); "Save current view…" persists the current route-query state via POST and toasts either way, naming the view (a failure also keeps the dialog open with the reason); own views can be deleted after confirmation. Emits `apply-view`. |
371
+ | `allowSavedViews` | Boolean | unset | Saved views (saved-views-ui). Unset, the sidebar's Views tab (save, apply, edit, share, add to menu, copy a default, delete) is on for every page with a sidebar and a source a view can be scoped to: register and schema, `entitySource` or `savedViewsScope`. `false` turns it off. `true` also forces the sidebar and adds the toolbar control: a Views dropdown listing the user's OpenRegister saved-search views (`GET /apps/openregister/api/views`). Applying one writes its stored filters/search/sort into the route query (`_search` and `_order` reserved keys + plain filter keys); "Save current view…" persists the current route-query state via POST and toasts either way, naming the view (a failure also keeps the dialog open with the reason); own views can be deleted after confirmation. Emits `apply-view`. |
372
372
  | `savedViewsScope` | String | `''` | Which pages share this page's saved views. By default a view is shared by every page over the same `register` and `schema` and shown on no other page, since a view is filters over one schema's fields. Set a name to share views across pages over different sources, or to keep two pages over one source apart. Written into the saved view's `query.scope` on save; views saved before scoping existed carry no scope and stay visible everywhere. |
373
373
  | `massActionNameField` | String | `'title'` | Property name used to display item names in dialogs |
374
374
  | `nameFormatter` | Function | `null` | Custom formatter for item names in dialogs; overrides `massActionNameField` |
@@ -1379,7 +1379,7 @@ import { isRowActionVisible, rowActionPayload } from '../../utils/rowActionItem.
1379
1379
  import { isNewTabClick, isNewTabHandled, isRowMiddleClick, markNewTabHandled, preventMiddleClickAutoscroll } from '../../utils/rowAuxClick.js'
1380
1380
  import { compileRowClass } from '../../utils/rowClassRules.js'
1381
1381
  import { DEFAULT_ROW_INDICATOR_CAP } from '../../utils/rowIndicators.js'
1382
- import { buildRouteQueryFromViewState, buildViewCreatePayload, defaultFilterViews, extractViewState, extractViewStateFromRouteQuery, normalizeSharedWith, savedViewScope, schemaSlug, viewMatchesScope } from '../../utils/savedViewHelpers.js'
1382
+ import { buildRouteQueryFromViewState, buildViewCreatePayload, defaultFilterViews, extractViewState, extractViewStateFromRouteQuery, normalizeSharedWith, pageOffersSavedViews, savedViewScope, schemaSlug, viewMatchesScope } from '../../utils/savedViewHelpers.js'
1383
1383
  import { savedViewBadge, savedViewOverrides } from '../../utils/savedViewOverrides.js'
1384
1384
  import { columnsFromSchema, fieldsFromSchema } from '../../utils/schema.js'
1385
1385
  import { resolveScopeLayout, resolveScopePresentation } from '../../utils/scopeListLayout.js'
@@ -1500,11 +1500,22 @@ function declaredFilterFields(props) {
1500
1500
  * @return {object} The `{ fieldKey: values }` map, empty when the link carries none.
1501
1501
  */
1502
1502
  function namedFiltersFromRoute(instance, props) {
1503
+ const route = instance && instance.proxy && instance.proxy.$route
1504
+ return namedFiltersFromQuery((route && route.query) || {}, props)
1505
+ }
1506
+
1507
+ /**
1508
+ * The named-source filters a query carries, as `namedFiltersFromRoute` reads them.
1509
+ *
1510
+ * @param {object} query The route query.
1511
+ * @param {object} props The CnIndexPage props.
1512
+ *
1513
+ * @return {object} The `{ fieldKey: values }` map.
1514
+ */
1515
+ function namedFiltersFromQuery(query, props) {
1503
1516
  if (!props.entitySource) {
1504
1517
  return {}
1505
1518
  }
1506
- const route = instance && instance.proxy && instance.proxy.$route
1507
- const query = (route && route.query) || {}
1508
1519
  const properties = declaredFilterFields(props)
1509
1520
  const out = {}
1510
1521
 
@@ -2947,9 +2958,11 @@ export default {
2947
2958
  },
2948
2959
 
2949
2960
  /**
2950
- * Opt-in flag for the saved-views control (saved-views-ui) rendered
2951
- * in the toolbar. Defaults to `false` — an app must explicitly
2952
- * enable it per page. When `true`, a Views dropdown lists the
2961
+ * Saved views (saved-views-ui). Unset, the sidebar's Views tab is on
2962
+ * for every page with a sidebar and a source a view can be scoped to
2963
+ * (register and schema, `entitySource` or `savedViewsScope`); `false`
2964
+ * turns it off. `true` also forces the sidebar and adds the toolbar
2965
+ * control, where a Views dropdown lists the
2953
2966
  * current user's OpenRegister saved-search views
2954
2967
  * (`GET /apps/openregister/api/views`); applying one writes its
2955
2968
  * stored filters/search/sort into the route query (reusing the
@@ -2961,7 +2974,7 @@ export default {
2961
2974
  */
2962
2975
  allowSavedViews: {
2963
2976
  type: Boolean,
2964
- default: false,
2977
+ default: null,
2965
2978
  },
2966
2979
 
2967
2980
  /**
@@ -5523,7 +5536,7 @@ export default {
5523
5536
  * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-6
5524
5537
  */
5525
5538
  savedViewsForSidebar() {
5526
- if (!this.allowSavedViews) {
5539
+ if (!this.savedViewsOffered) {
5527
5540
  return null
5528
5541
  }
5529
5542
  return {
@@ -6664,7 +6677,16 @@ export default {
6664
6677
  * @return {{ scope: string, register: string, schema: object|string|null }} The page's source.
6665
6678
  */
6666
6679
  savedViewsPage() {
6667
- return { scope: this.savedViewsScope, register: this.register, schema: this.schema }
6680
+ return { scope: this.savedViewsScope, register: this.register, schema: this.schema, source: this.entitySource }
6681
+ },
6682
+
6683
+ /**
6684
+ * Whether this page offers saved views in its sidebar.
6685
+ *
6686
+ * @return {boolean}
6687
+ */
6688
+ savedViewsOffered() {
6689
+ return pageOffersSavedViews(this)
6668
6690
  },
6669
6691
 
6670
6692
  /**
@@ -8237,7 +8259,7 @@ export default {
8237
8259
  created() {
8238
8260
  this.applyViewModeFromRoute()
8239
8261
  this.pushAiContext()
8240
- if (this.allowSavedViews) {
8262
+ if (this.savedViewsOffered) {
8241
8263
  this.fetchSavedViews()
8242
8264
  }
8243
8265
  this.selfActions = createSelfModeActions({
@@ -11345,8 +11367,8 @@ export default {
11345
11367
 
11346
11368
  /**
11347
11369
  * Fetch the current user's saved views (own + public) from
11348
- * OpenRegister's views API. Called from created() when
11349
- * `allowSavedViews` is enabled.
11370
+ * OpenRegister's views API. Called from created() when the page
11371
+ * offers saved views.
11350
11372
  */
11351
11373
  async fetchSavedViews() {
11352
11374
  this.savedViewsLoading = true
@@ -11416,6 +11438,9 @@ export default {
11416
11438
  query.savedView = this.appliedSavedViewId
11417
11439
  }
11418
11440
  const nav = this.$router.replace({ query })
11441
+ if (this.isNamedSource) {
11442
+ this.namedActiveFilters = namedFiltersFromQuery(query, this)
11443
+ }
11419
11444
  // Swallow the duplicate-navigation rejection (Vue Router 3)
11420
11445
  // when the applied view matches the current query.
11421
11446
  if (nav && typeof nav.catch === 'function') {
@@ -216,7 +216,7 @@ With admin-only filter control:
216
216
  | `propertiesGroupLabel` | String | `''` | Override label for the schema properties group. Falls back to `schema.title` |
217
217
  | `defaultTab` | String | `'search-tab'` | Tab id that is active when the sidebar opens. Built-in ids: `'search-tab'`, `'columns-tab'` |
218
218
  | `activeTab` | String | `''` | Switch to a tab while mounted, e.g. `'filters-tab'` |
219
- | `savedViews` | Object | `null` | The page's saved views, shown in a third tab, Filters (CnSavedViewsPanel); every intent comes back as one `saved-view` event `{ action, view }` |
219
+ | `savedViews` | Object | `null` | The page's saved views, shown in a third tab, Views (`filters-tab`, CnSavedViewsPanel); every intent comes back as one `saved-view` event `{ action, view }` |
220
220
  | `userIsAdmin` | Boolean | `true` | When `false`, schema properties with `adminOnly: true` are hidden from filters |
221
221
  | `filterFields` | Object | `null` | Filter declarations for a page with no schema of its own. Wins over `schema`, and feeds the Search tab only |
222
222
 
@@ -287,11 +287,11 @@
287
287
  </div>
288
288
  </NcAppSidebarTab>
289
289
 
290
- <!-- Filters Tab: the page's saved views (save, list, manage, add to menu). -->
290
+ <!-- Views tab (id filters-tab): the page's saved views (save, list, manage, add to menu). -->
291
291
  <NcAppSidebarTab
292
292
  v-if="savedViews"
293
293
  id="filters-tab"
294
- :name="t('nextcloud-vue', 'Filters')"
294
+ :name="t('nextcloud-vue', 'Views')"
295
295
  :order="3">
296
296
  <template #icon>
297
297
  <BookmarkOutline :size="20" />
@@ -93,6 +93,7 @@
93
93
 
94
94
  <script>
95
95
  import axios from '@nextcloud/axios'
96
+ import { subscribe, unsubscribe } from '@nextcloud/event-bus'
96
97
  import { translate as t } from '@nextcloud/l10n'
97
98
  import { generateUrl } from '@nextcloud/router'
98
99
  import { NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
@@ -109,7 +110,8 @@ import { openRowTarget } from '../../utils/linkNavigation.js'
109
110
  * not be read is named at the top rather than folded into "nothing is
110
111
  * waiting". A group can be hidden until tomorrow, through the server, so the
111
112
  * choice follows the reader to another device. dossiq's personal queue is
112
- * the first; pipelinq's queue has the same shape.
113
+ * the first; pipelinq's queue has the same shape. It reads again on
114
+ * `cn:page:refresh`, so work planned or closed in a dialog shows at once.
113
115
  *
114
116
  * `content`:
115
117
  * - `source`: an endpointSource answering `{ groups, hiddenGroups?, unavailable? }`.
@@ -176,9 +178,14 @@ export default {
176
178
  },
177
179
 
178
180
  mounted() {
181
+ subscribe('cn:page:refresh', this.load)
179
182
  this.load()
180
183
  },
181
184
 
185
+ beforeUnmount() {
186
+ unsubscribe('cn:page:refresh', this.load)
187
+ },
188
+
182
189
  methods: {
183
190
  t,
184
191
 
package/src/css/board.css CHANGED
@@ -55,10 +55,13 @@
55
55
  margin-inline-start: auto;
56
56
  padding: 1px 8px;
57
57
  border-radius: 10px;
58
+ --nldesign-color-on-surface: var(--cn-board-text-soft, var(--color-main-text));
58
59
  background-color: var(--color-main-background);
59
- color: var(--color-main-text);
60
+ color: var(--cn-board-text-soft, var(--color-main-text));
60
61
  font-size: 12px;
61
- font-weight: 400;
62
+ font-weight: 700;
63
+ /* 16px tall on its 1px pad (DqWerkbord's column counts). */
64
+ line-height: 14px;
62
65
  }
63
66
 
64
67
  /* A column at its limit: the count in the error colour; "full" is said in words too. */
@@ -75,7 +75,7 @@
75
75
  height: var(--cn-board-control-height) !important;
76
76
  min-height: var(--cn-board-control-height) !important;
77
77
  padding: 0 14px !important;
78
- border: 1px solid var(--color-border-dark) !important;
78
+ border: 1px solid var(--cn-board-control-border) !important;
79
79
  border-radius: var(--cn-board-control-radius) !important;
80
80
  background: var(--color-main-background) !important;
81
81
  color: var(--color-main-text) !important;
@@ -88,13 +88,13 @@
88
88
  background: var(--color-background-hover) !important;
89
89
  }
90
90
 
91
- /* Each header icon is 16px with 8px to its label; NcButton draws 20px in a 30px slot. */
91
+ /* Each header icon is 16px; NcButton draws 20px in a 30px slot. The 8px to
92
+ the label is the board's icon-and-label gap (look-board.css). */
92
93
  .cn-look-board .cn-detail-page .cn-detail-page__header-actions > .button-vue--secondary .button-vue__icon {
93
94
  width: 16px;
94
95
  min-width: 16px;
95
96
  height: 16px;
96
97
  min-height: 16px;
97
- margin-inline-end: 8px;
98
98
  }
99
99
 
100
100
  .cn-look-board .cn-detail-page .cn-detail-page__header-actions > .button-vue--secondary .button-vue__icon svg {
@@ -102,6 +102,17 @@
102
102
  height: 16px;
103
103
  }
104
104
 
105
+ /* Row 3: the Follow control under the breadcrumb, on the full width.
106
+ @spec openspec/changes/board-follow-row/specs/board-follow-row/spec.md#requirement-the-board-follow-control-sits-under-the-breadcrumb */
107
+ .cn-look-board .cn-detail-page__header-row3 {
108
+ align-items: center;
109
+ display: flex;
110
+ flex: 1 0 100%;
111
+ flex-wrap: wrap;
112
+ gap: 8px;
113
+ min-width: 0;
114
+ }
115
+
105
116
  /* Row 2: pills, breadcrumb, dot, meta, chips. */
106
117
  .cn-look-board .cn-detail-page__header-row2 {
107
118
  align-items: center;
@@ -259,14 +270,14 @@
259
270
  --nldesign-component-button-border-radius: 14px;
260
271
  --nldesign-component-button-secondary-action-color: var(--color-main-text);
261
272
  --nldesign-component-button-secondary-action-background-color: var(--color-main-background);
262
- --nldesign-component-button-secondary-action-border-color: var(--color-border-dark);
273
+ --nldesign-component-button-secondary-action-border-color: var(--cn-board-control-border);
263
274
  --nldesign-component-button-secondary-action-hover-background-color: var(--color-background-hover);
264
275
  box-sizing: border-box;
265
276
  height: 28px;
266
277
  min-height: 28px;
267
278
  min-width: 0;
268
279
  padding: 0 10px;
269
- border: 1px solid var(--color-border-dark);
280
+ border: 1px solid var(--cn-board-control-border);
270
281
  border-radius: 14px;
271
282
  background: var(--color-main-background);
272
283
  color: var(--color-main-text);
@@ -404,13 +415,14 @@
404
415
 
405
416
  /* The Actions button at the end of the strip: at the top, 8px in from the
406
417
  end and 8px clear of the card, so the tabs, aligned to the bottom, sit 6px
407
- lower than the button (DqZaak "Acties"). */
418
+ lower than the button, 12px in at both sides (DqZaak "Acties"). */
408
419
  .cn-look-board .cn-tabs--board .cn-tabs__nav-end {
409
420
  align-self: flex-start;
410
421
  margin: 0 8px 8px 0;
411
422
  }
412
423
 
413
424
  .cn-look-board .cn-tabs--board .cn-tabs__nav-end .button-vue {
425
+ --button-padding: 12px;
414
426
  min-height: 34px;
415
427
  block-size: 34px;
416
428
  }
@@ -734,7 +746,7 @@
734
746
  .cn-look-board .cn-activity-tab--board .cn-activity-tab__kind {
735
747
  align-items: center;
736
748
  background: var(--color-main-background);
737
- border: 1px solid var(--color-border-dark);
749
+ border: 1px solid var(--cn-board-control-border);
738
750
  border-radius: 18px;
739
751
  color: var(--color-main-text);
740
752
  cursor: pointer;