@conduction/nextcloud-vue 2.115.0 → 2.116.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 (117) hide show
  1. package/dist/esm/components/CnActionButtons/CnActionButtons.vue.js +1 -1
  2. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js +92 -0
  3. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js +2 -2
  5. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js.map +1 -1
  6. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue.js +9 -0
  7. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue.js.map +1 -0
  8. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue2.js +145 -0
  9. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue2.js.map +1 -0
  10. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue3.js +58 -0
  11. package/dist/esm/components/CnChecklistWidget/CnChecklistWidget.vue3.js.map +1 -0
  12. package/dist/esm/components/CnChecklistWidget/index.js +28 -0
  13. package/dist/esm/components/CnChecklistWidget/index.js.map +1 -0
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +25 -0
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +1 -1
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +376 -51
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +364 -285
  22. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  24. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -0
  25. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  26. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +6 -4
  27. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  28. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue.js +1 -1
  29. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js +48 -1
  30. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue2.js.map +1 -1
  31. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js +11 -2
  32. package/dist/esm/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue.js +1 -1
  34. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue2.js +11 -3
  35. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue2.js.map +1 -1
  36. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue3.js +8 -3
  37. package/dist/esm/components/CnMacrosWidget/CnMacrosWidget.vue3.js.map +1 -1
  38. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  39. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +55 -2
  40. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  42. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  43. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +94 -4
  44. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +248 -239
  46. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js +9 -0
  48. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js.map +1 -0
  49. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js +282 -0
  50. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js.map +1 -0
  51. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js +202 -0
  52. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js.map +1 -0
  53. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  54. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +10 -5
  55. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  56. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  58. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  59. package/dist/esm/composables/indexSources.js +64 -0
  60. package/dist/esm/composables/indexSources.js.map +1 -1
  61. package/dist/esm/composables/useTaskInboxStore.js +85 -0
  62. package/dist/esm/composables/useTaskInboxStore.js.map +1 -1
  63. package/dist/esm/dialogs/CnAccessPurposeDialog.vue.js +9 -0
  64. package/dist/esm/dialogs/CnAccessPurposeDialog.vue.js.map +1 -0
  65. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js +107 -0
  66. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js.map +1 -0
  67. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js +110 -0
  68. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js.map +1 -0
  69. package/dist/esm/index.js +1 -0
  70. package/dist/esm/index.js.map +1 -1
  71. package/dist/esm/l10n/en.json.js +19 -1
  72. package/dist/esm/l10n/en.json.js.map +1 -1
  73. package/dist/esm/l10n/nl.json.js +23 -1
  74. package/dist/esm/l10n/nl.json.js.map +1 -1
  75. package/dist/esm/library-style.js +1 -1
  76. package/dist/esm/nextcloud-vue.css +1054 -911
  77. package/dist/esm/utils/actionsDispatcher.js +10 -5
  78. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  79. package/dist/esm/utils/detailActionModel.js +6 -2
  80. package/dist/esm/utils/detailActionModel.js.map +1 -1
  81. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  82. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  83. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  84. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  85. package/dist/esm/utils/widgetDispatch.js +1 -1
  86. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  87. package/dist/nextcloud-vue.cjs.js +17078 -15224
  88. package/dist/nextcloud-vue.cjs.js.map +2 -2
  89. package/dist/nextcloud-vue.css +1054 -911
  90. package/l10n/en.json +19 -1
  91. package/l10n/nl.json +23 -1
  92. package/package.json +1 -1
  93. package/src/components/CnActionButtons/CnActionButtons.vue +93 -1
  94. package/src/components/CnChecklistWidget/CnChecklistWidget.vue +206 -0
  95. package/src/components/CnChecklistWidget/index.js +27 -0
  96. package/src/components/CnDashboardPage/CnDashboardPage.vue +26 -1
  97. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  98. package/src/components/CnDetailPage/CnDetailPage.vue +377 -10
  99. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +15 -2
  100. package/src/components/CnGroupedCardsWidget/CnGroupedCardsWidget.vue +59 -1
  101. package/src/components/CnMacrosWidget/CnMacrosWidget.vue +16 -6
  102. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +55 -2
  103. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +101 -3
  104. package/src/components/CnRecordListWidget/CnRecordNoteRows.vue +508 -0
  105. package/src/components/CnTabsWidget/CnTabsWidget.vue +10 -5
  106. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  107. package/src/components/index.js +1 -0
  108. package/src/composables/indexSources.js +64 -0
  109. package/src/composables/useTaskInboxStore.js +85 -0
  110. package/src/dialogs/CnAccessPurposeDialog.vue +183 -0
  111. package/src/index.js +1 -0
  112. package/src/schemas/app-manifest-v2.schema.json +49 -3
  113. package/src/utils/actionsDispatcher.js +10 -5
  114. package/src/utils/detailActionModel.js +6 -2
  115. package/src/utils/libraryWidgetKeys.js +1 -0
  116. package/src/utils/validateManifestV2.compiled.js +3 -3
  117. package/src/utils/widgetDispatch.js +1 -1
@@ -44,7 +44,7 @@
44
44
  <!-- Breadcrumb line (manifest `config.breadcrumb`): the list the record
45
45
  belongs to, then the record itself as the current crumb. -->
46
46
  <CnBreadcrumbs
47
- v-if="breadcrumbCrumbs.length > 0 && !objectNotFound && !isBoard"
47
+ v-if="breadcrumbCrumbs.length > 0 && !objectNotFound && !contentWithheld && !isBoard"
48
48
  class="cn-detail-page__breadcrumbs"
49
49
  :crumbs="breadcrumbCrumbs"
50
50
  :rootText="breadcrumbCrumbs[0].icon === undefined"
@@ -52,7 +52,7 @@
52
52
  data-testid="cn-detail-page-breadcrumbs" />
53
53
  <!-- Header -->
54
54
  <div
55
- v-if="!objectNotFound"
55
+ v-if="!objectNotFound && !contentWithheld"
56
56
  class="cn-detail-page__header"
57
57
  :class="{ 'cn-detail-page__header--card': headerCard && !isBoard, 'cn-detail-page__header--with-widget': headerWidgetDef !== null, 'cn-detail-page__header--board': isBoard }"
58
58
  data-testid="cn-detail-page-header">
@@ -264,6 +264,22 @@
264
264
  @click="onPrimaryActionClick">
265
265
  {{ effectivePrimaryLabel }}
266
266
  </NcButton>
267
+ <!-- The record source's own verbs (a task's claim, hand back,
268
+ complete), offered from the record's state. The ones a source
269
+ marks `menu` join the Actions menu instead. -->
270
+ <NcButton
271
+ v-for="verb in sourceVerbButtons"
272
+ :key="`verb-${verb.id}`"
273
+ variant="secondary"
274
+ :disabled="sourceVerbBusy"
275
+ :data-testid="`cn-detail-page-verb-${verb.id}`"
276
+ @click="runSourceVerb(verb)">
277
+ <template v-if="verb.icon" #icon>
278
+ <CnIcon v-if="typeof verb.icon === 'string'" :name="verb.icon" :size="20" />
279
+ <component :is="verb.icon" v-else :size="20" />
280
+ </template>
281
+ {{ verb.label }}
282
+ </NcButton>
267
283
  <!-- Quick actions (manifest `config.quickActions`): the few
268
284
  actions a handler reaches for on every record, always
269
285
  visible. At most three, so the row cannot grow back into
@@ -659,6 +675,32 @@
659
675
  </slot>
660
676
  </div>
661
677
 
678
+ <!-- Access purpose (`accessPurpose`): nothing of the record shows until
679
+ the reader has stated why they open it and the purpose is recorded. -->
680
+ <div v-else-if="contentWithheld" class="cn-detail-page__access-purpose" data-testid="cn-detail-page-access-purpose">
681
+ <CnAccessPurposeDialog
682
+ v-if="accessPurposeAsking"
683
+ :title="accessPurposeText('title')"
684
+ :description="accessPurposeText('description')"
685
+ :options="accessPurposeOptions"
686
+ :loading="accessPurposeBusy"
687
+ :error="accessPurposeError"
688
+ @submit="recordAccessPurpose"
689
+ @cancel="accessPurposeAsking = false" />
690
+ <NcEmptyContent
691
+ v-else-if="accessPurposeRequired"
692
+ :name="t('nextcloud-vue', 'State a purpose to open this')"
693
+ :description="t('nextcloud-vue', 'This record holds special categories of personal data. It opens once your purpose is recorded.')"
694
+ data-testid="cn-detail-page-access-purpose-refused">
695
+ <template #action>
696
+ <NcButton variant="primary" data-testid="cn-detail-page-access-purpose-ask" @click="accessPurposeAsking = true">
697
+ {{ t('nextcloud-vue', 'State a purpose') }}
698
+ </NcButton>
699
+ </template>
700
+ </NcEmptyContent>
701
+ <NcLoadingIcon v-else :size="32" :name="loadingLabel" />
702
+ </div>
703
+
662
704
  <!-- Loading state -->
663
705
  <div v-else-if="showLoadingState" class="cn-detail-page__loading">
664
706
  <NcLoadingIcon :size="32" />
@@ -1231,6 +1273,7 @@ import Plus from 'vue-material-design-icons/Plus.vue'
1231
1273
  import Refresh from 'vue-material-design-icons/Refresh.vue'
1232
1274
  import Restore from 'vue-material-design-icons/Restore.vue'
1233
1275
  import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
1276
+ import CnAccessPurposeDialog from '../../dialogs/CnAccessPurposeDialog.vue'
1234
1277
  import CnRelationLinkModal from '../../dialogs/CnRelationLinkModal.vue'
1235
1278
  import CnWidgetStyleEditorModal from '../../dialogs/CnWidgetStyleEditorModal.vue'
1236
1279
  import CnActionsMenu from '../CnActionsMenu/CnActionsMenu.vue'
@@ -1280,6 +1323,7 @@ import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
1280
1323
  import { firstFilled } from '../../utils/fillTemplate.js'
1281
1324
  import { cnGridCellStyle, hasGridRow } from '../../utils/grid.js'
1282
1325
  import { isActivityWidget, resolveHeaderMeta } from '../../utils/headerMeta.js'
1326
+ import { prefixUrl } from '../../utils/headers.js'
1283
1327
  import { patchStoredSelf } from '../../utils/patchStoredSelf.js'
1284
1328
  import { setReadState } from '../../utils/recordInteractions.js'
1285
1329
  import { saveEdited } from '../../utils/saveEdited.js'
@@ -1374,6 +1418,25 @@ const PAGE_REFRESH_CHANNEL = 'cn:page:refresh'
1374
1418
  /** Surfaces understood by the pluggable integration registry (AD-19). */
1375
1419
  const INTEGRATION_SURFACES = ['user-dashboard', 'app-dashboard', 'detail-page', 'single-entity']
1376
1420
 
1421
+ /** The session storage key that lists the records whose access purpose is recorded. */
1422
+ const ACCESS_PURPOSE_SESSION_KEY = 'cn-access-purposes'
1423
+
1424
+ /**
1425
+ * The records whose access purpose this browser session recorded. Empty when
1426
+ * session storage cannot be read (a private window, blocked site data).
1427
+ *
1428
+ * @return {Array<string>} The record keys.
1429
+ */
1430
+ function readGrantedPurposes() {
1431
+ try {
1432
+ const raw = window.sessionStorage.getItem(ACCESS_PURPOSE_SESSION_KEY)
1433
+ const list = raw ? JSON.parse(raw) : []
1434
+ return Array.isArray(list) ? list.filter((key) => typeof key === 'string') : []
1435
+ } catch {
1436
+ return []
1437
+ }
1438
+ }
1439
+
1377
1440
  /**
1378
1441
  * CnDetailPage — Generic detail/overview page.
1379
1442
  *
@@ -1450,6 +1513,7 @@ export default {
1450
1513
 
1451
1514
  components: {
1452
1515
  ChevronLeft,
1516
+ CnAccessPurposeDialog,
1453
1517
  ChevronRight,
1454
1518
  NcActionButton,
1455
1519
  NcActionCaption,
@@ -1547,6 +1611,7 @@ export default {
1547
1611
  const isBoard = computed(() => this.isBoard)
1548
1612
  const tabsLabel = computed(() => this.resolvedTabsLabel)
1549
1613
  const visibilityRows = computed(() => (this.visibilityRowsPending ? null : this.visibilityRows))
1614
+ const canCheckItems = computed(() => this.recordCanCheckItems)
1550
1615
  return {
1551
1616
  // The page's own look reaches the tabs, the cards and the activity
1552
1617
  // below it, also when it came from the `look` prop and not the app.
@@ -1557,6 +1622,15 @@ export default {
1557
1622
  },
1558
1623
  },
1559
1624
 
1625
+ // The record source's checklist writes, for a `checklist` widget.
1626
+ cnRecordChecklist: {
1627
+ get canCheck() {
1628
+ return canCheckItems.value
1629
+ },
1630
+
1631
+ check: (itemId, checked) => this.checkSourceItem(itemId, checked),
1632
+ },
1633
+
1560
1634
  // The open body tab, reported by a `tabs` widget, so a side entry
1561
1635
  // with `tabs` shows only beside those tabs.
1562
1636
  cnDetailReportTab: (id) => {
@@ -1954,6 +2028,20 @@ export default {
1954
2028
  default: '',
1955
2029
  },
1956
2030
 
2031
+ /**
2032
+ * App routes that replace a record source's own route for a verb, by
2033
+ * verb id (manifest `config.verbEndpoints`), such as
2034
+ * `{ "complete": "/apps/dossiq/api/case-tasks/{id}/complete" }` for an
2035
+ * app that checks something of its own before the engine completes the
2036
+ * task. `{id}` is the record id. Only read with `entitySource`.
2037
+ *
2038
+ * @type {object|null}
2039
+ */
2040
+ verbEndpoints: {
2041
+ type: Object,
2042
+ default: null,
2043
+ },
2044
+
1957
2045
  /**
1958
2046
  * OpenRegister register slug. Pair with `schema` to opt into the
1959
2047
  * schema-driven mode: the page fuses the two into an internal
@@ -2517,9 +2605,11 @@ export default {
2517
2605
  * shows nothing at 0. With `resolve: { register, schema, labelField?,
2518
2606
  * colorField?, iconField? }` the pill shows the referenced record's
2519
2607
  * label, takes its tone from that record's colour (an NL Design System
2520
- * hue such as `red`, or a badge role) and shows its icon.
2608
+ * hue such as `red`, or a badge role) and shows its icon. `values`
2609
+ * draws a `field` pill only for those raw values (a priority pill for
2610
+ * `high` and `urgent` only).
2521
2611
  *
2522
- * @type {{field?: string, text?: string, countFrom?: object, countLabel?: object, colorMap?: object, labels?: object, variant?: string, resolve?: object}|null}
2612
+ * @type {{field?: string, text?: string, countFrom?: object, countLabel?: object, colorMap?: object, labels?: object, variant?: string, resolve?: object, values?: Array<string>}|null}
2523
2613
  */
2524
2614
  typePill: {
2525
2615
  type: Object,
@@ -2697,6 +2787,28 @@ export default {
2697
2787
  default: () => ({}),
2698
2788
  },
2699
2789
 
2790
+ /**
2791
+ * Ask why the reader opens the record before the page shows it, and
2792
+ * record the answer (manifest `config.accessPurpose`). `{ when, options,
2793
+ * url, title?, description? }`: `when` is a condition in the
2794
+ * `visibleWhen` grammar on the record and its `visibilityContext`
2795
+ * records (DqSociaalDomein: the case type lists special categories of
2796
+ * personal data); `options` the purposes offered (`{ value, label }`,
2797
+ * "Other" with a written purpose is always added); `url` the app
2798
+ * endpoint that records the purpose (POST `{ purpose, note }`, `{objectId}`
2799
+ * filled in). The page shows nothing of the record until the endpoint
2800
+ * accepts, and does not ask again for the same record in the same
2801
+ * browser session. A refusal keeps the record closed. `null` (the
2802
+ * default) asks nothing.
2803
+ *
2804
+ * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
2805
+ * @type {object|null}
2806
+ */
2807
+ accessPurpose: {
2808
+ type: Object,
2809
+ default: null,
2810
+ },
2811
+
2700
2812
  /**
2701
2813
  * Whether the current user administers this instance. Shows the
2702
2814
  * `adminOnly` header actions. `null` (the default) reads it from
@@ -2939,6 +3051,14 @@ export default {
2939
3051
 
2940
3052
  data() {
2941
3053
  return {
3054
+ /** The session keys of the records whose access purpose is recorded. */
3055
+ accessPurposeGranted: readGrantedPurposes(),
3056
+ /** Whether the purpose dialog is open; closing it without a purpose keeps the record closed. */
3057
+ accessPurposeAsking: true,
3058
+ /** Whether a purpose is being recorded. */
3059
+ accessPurposeBusy: false,
3060
+ /** Why the last purpose was refused. */
3061
+ accessPurposeError: '',
2942
3062
  /** The id of the open body tab a `tabs` widget reported, '' before one did. */
2943
3063
  activeBodyTab: '',
2944
3064
  /**
@@ -2989,6 +3109,8 @@ export default {
2989
3109
  * menu dispatches without reaching back into that component.
2990
3110
  */
2991
3111
  menuHeaderActions: [],
3112
+ /** Whether a record-source verb is in flight, so a double click cannot run it twice. */
3113
+ sourceVerbBusy: false,
2992
3114
  /** Lifecycle transitions CnLifecycleActions hands the Actions menu. */
2993
3115
  lifecycleMenuEntries: [],
2994
3116
  /** Whether the per-widget style/config editor modal is open. */
@@ -3440,7 +3562,64 @@ export default {
3440
3562
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-next-step-card
3441
3563
  */
3442
3564
  showsBody() {
3443
- return !this.objectNotFound && !this.showLoadingState && !this.error && !this.empty
3565
+ return !this.objectNotFound && !this.contentWithheld && !this.showLoadingState && !this.error && !this.empty
3566
+ },
3567
+
3568
+ /**
3569
+ * Whether the loaded record asks for a purpose before it opens
3570
+ * (`accessPurpose.when` holds for it). Undecided, and so withheld,
3571
+ * while the record or the records its condition reads are on their way.
3572
+ *
3573
+ * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
3574
+ * @return {boolean}
3575
+ */
3576
+ accessPurposeRequired() {
3577
+ const gate = this.accessPurpose
3578
+ if (!gate || typeof gate !== 'object' || !gate.when || !this.widgetVisibilityData || this.visibilityRowsPending) {
3579
+ return false
3580
+ }
3581
+ return detailWidgetVisible({ visibleWhen: gate.when }, this.widgetVisibilityData)
3582
+ },
3583
+
3584
+ /**
3585
+ * Whether the page holds the record back: it asks for a purpose and
3586
+ * none is recorded for this record in this session yet, or it cannot
3587
+ * tell yet whether it asks.
3588
+ *
3589
+ * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
3590
+ * @return {boolean}
3591
+ */
3592
+ contentWithheld() {
3593
+ const gate = this.accessPurpose
3594
+ if (!gate || typeof gate !== 'object' || !gate.when || this.objectNotFound) {
3595
+ return false
3596
+ }
3597
+ if (this.accessPurposeGranted.includes(this.accessPurposeKey)) {
3598
+ return false
3599
+ }
3600
+ const undecided = !this.widgetVisibilityData || this.visibilityRowsPending
3601
+ return undecided || this.accessPurposeRequired
3602
+ },
3603
+
3604
+ /**
3605
+ * The session key of the purpose recorded for this record.
3606
+ *
3607
+ * @return {string}
3608
+ */
3609
+ accessPurposeKey() {
3610
+ return `cn-access-purpose:${this.resolvedObjectType || ''}:${this.objectId || ''}`
3611
+ },
3612
+
3613
+ /**
3614
+ * The purposes offered, through the app's translate.
3615
+ *
3616
+ * @return {Array<{value: string, label: string}>}
3617
+ */
3618
+ accessPurposeOptions() {
3619
+ const options = Array.isArray(this.accessPurpose?.options) ? this.accessPurpose.options : []
3620
+ return options
3621
+ .filter((option) => option && typeof option.value === 'string' && option.value !== '')
3622
+ .map((option) => ({ value: option.value, label: this.cnTranslate(option.label || option.value) }))
3444
3623
  },
3445
3624
 
3446
3625
  /**
@@ -3929,7 +4108,70 @@ export default {
3929
4108
  const inMenu = pinned.size > 0
3930
4109
  ? this.menuHeaderActions.filter((entry) => !pinned.has(entry.id))
3931
4110
  : this.menuHeaderActions
3932
- return [...lifecycle, ...inMenu]
4111
+ return [...this.sourceVerbMenuEntries, ...lifecycle, ...inMenu]
4112
+ },
4113
+
4114
+ /**
4115
+ * The verbs the record source offers on the record in view, read off
4116
+ * the record's own state by the source. Empty without a source that
4117
+ * offers verbs, before the record is read, and when it was not found.
4118
+ *
4119
+ * @return {Array<object>} The verbs.
4120
+ * @spec openspec/changes/record-source-verbs/specs/record-source-verbs/spec.md#requirement-a-record-source-offers-header-verbs
4121
+ */
4122
+ sourceVerbs() {
4123
+ const source = this.recordSource
4124
+ if (!source || typeof source.verbs !== 'function' || typeof source.runVerb !== 'function') {
4125
+ return []
4126
+ }
4127
+ if (this.objectNotFound || !this.currentObject) {
4128
+ return []
4129
+ }
4130
+ const verbs = source.verbs(this.currentObject)
4131
+ return Array.isArray(verbs) ? verbs.filter((verb) => verb && typeof verb.id === 'string' && verb.id !== '') : []
4132
+ },
4133
+
4134
+ /**
4135
+ * Whether the record source can tick the checklist of the record in
4136
+ * view: it writes items, and says this record can still be ticked.
4137
+ *
4138
+ * @return {boolean}
4139
+ * @spec openspec/changes/record-checklist-widget/specs/record-checklist-widget/spec.md#requirement-a-checklist-widget-ticks-items-through-the-record-source
4140
+ */
4141
+ recordCanCheckItems() {
4142
+ const source = this.recordSource
4143
+ if (!source || typeof source.checkItem !== 'function' || !this.currentObject || this.objectNotFound) {
4144
+ return false
4145
+ }
4146
+ return typeof source.canCheckItems !== 'function' || source.canCheckItems(this.currentObject) === true
4147
+ },
4148
+
4149
+ /**
4150
+ * The source verbs drawn as header buttons.
4151
+ *
4152
+ * @return {Array<object>} The verbs.
4153
+ */
4154
+ sourceVerbButtons() {
4155
+ return this.sourceVerbs.filter((verb) => verb.menu !== true)
4156
+ },
4157
+
4158
+ /**
4159
+ * The source verbs listed in the Actions menu, as menu entries.
4160
+ *
4161
+ * @return {Array<object>} Menu-ready entries.
4162
+ */
4163
+ sourceVerbMenuEntries() {
4164
+ return this.sourceVerbs
4165
+ .filter((verb) => verb.menu === true)
4166
+ .map((verb) => ({
4167
+ id: `cn-source-verb-${verb.id}`,
4168
+ label: verb.label,
4169
+ testid: `cn-detail-page-verb-${verb.id}`,
4170
+ iconName: typeof verb.icon === 'string' ? verb.icon : '',
4171
+ disabled: this.sourceVerbBusy,
4172
+ pressed: null,
4173
+ run: () => this.runSourceVerb(verb),
4174
+ }))
3933
4175
  },
3934
4176
 
3935
4177
  /**
@@ -4919,6 +5161,16 @@ export default {
4919
5161
  this.syncSidebarState()
4920
5162
  },
4921
5163
 
5164
+ contentWithheld() {
5165
+ this.syncSidebarState()
5166
+ },
5167
+
5168
+ /** Another record asks its own purpose: the dialog opens again for it. */
5169
+ accessPurposeKey() {
5170
+ this.accessPurposeAsking = true
5171
+ this.accessPurposeError = ''
5172
+ },
5173
+
4922
5174
  // Deep, like `sidebarTabs` / `sidebarProps` below: the manifest editors
4923
5175
  // mutate `page.config.sidebar` IN PLACE, so the reference CnPageRenderer
4924
5176
  // forwards never changes. A shallow watcher missed every such edit —
@@ -5095,6 +5347,52 @@ export default {
5095
5347
  },
5096
5348
 
5097
5349
  methods: {
5350
+ /**
5351
+ * A text of the access purpose gate through the app's translate, '' when unset.
5352
+ *
5353
+ * @param {string} key `title` or `description`.
5354
+ * @return {string}
5355
+ */
5356
+ accessPurposeText(key) {
5357
+ const text = this.accessPurpose && this.accessPurpose[key]
5358
+ return typeof text === 'string' && text !== '' ? this.cnTranslate(text) : ''
5359
+ },
5360
+
5361
+ /**
5362
+ * Record the reader's purpose with the app's endpoint, then open the
5363
+ * record and remember it for this browser session. A refusal (or a
5364
+ * failure to record) keeps the record closed and says why.
5365
+ *
5366
+ * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
5367
+ * @param {{purpose: string, note: string}} answer The chosen purpose.
5368
+ * @return {Promise<void>}
5369
+ */
5370
+ async recordAccessPurpose(answer) {
5371
+ const url = String(this.accessPurpose?.url || '').replace(/\{objectId\}/g, encodeURIComponent(String(this.objectId || '')))
5372
+ if (url === '') {
5373
+ this.accessPurposeError = t('nextcloud-vue', 'This page cannot record a purpose, so the record stays closed.')
5374
+ return
5375
+ }
5376
+ this.accessPurposeBusy = true
5377
+ this.accessPurposeError = ''
5378
+ try {
5379
+ const { default: axios } = await import('@nextcloud/axios')
5380
+ await axios.post(prefixUrl(url), { purpose: answer.purpose, note: answer.note })
5381
+ const key = this.accessPurposeKey
5382
+ this.accessPurposeGranted = [...new Set([...this.accessPurposeGranted, key])]
5383
+ try {
5384
+ window.sessionStorage.setItem(ACCESS_PURPOSE_SESSION_KEY, JSON.stringify(this.accessPurposeGranted))
5385
+ } catch {
5386
+ // Without session storage the purpose is asked again on the next visit.
5387
+ }
5388
+ } catch (error) {
5389
+ const data = error?.response?.data
5390
+ this.accessPurposeError = (data && (data.message || data.error)) || t('nextcloud-vue', 'Your purpose could not be recorded, so the record stays closed.')
5391
+ } finally {
5392
+ this.accessPurposeBusy = false
5393
+ }
5394
+ },
5395
+
5098
5396
  /**
5099
5397
  * The widget definitions with each `tabs` widget's tabs narrowed and
5100
5398
  * ordered by `pickedTabIds`; the definitions as given without a pick.
@@ -5552,6 +5850,65 @@ export default {
5552
5850
  this.$emit('transitioned', payload)
5553
5851
  },
5554
5852
 
5853
+ /**
5854
+ * Tick or untick one checklist item through the record source. The
5855
+ * source replaces its record with the answer; a refusal is shown with
5856
+ * the server's message and leaves the item as it was.
5857
+ *
5858
+ * @param {string} itemId The item id.
5859
+ * @param {boolean} checked The new state.
5860
+ * @return {Promise<boolean>} Whether the write was taken.
5861
+ * @spec openspec/changes/record-checklist-widget/specs/record-checklist-widget/spec.md#requirement-a-checklist-widget-ticks-items-through-the-record-source
5862
+ */
5863
+ async checkSourceItem(itemId, checked) {
5864
+ const source = this.recordSource
5865
+ const objectId = this.objectId
5866
+ if (!this.recordCanCheckItems || !objectId) {
5867
+ return false
5868
+ }
5869
+ const outcome = await source.checkItem(String(objectId), String(itemId), checked === true)
5870
+ if (!outcome || outcome.ok !== true) {
5871
+ const { showError } = await import('@nextcloud/dialogs')
5872
+ showError(String(outcome?.message || '').trim() || t('nextcloud-vue', 'This could not be done. Try again.'))
5873
+ return false
5874
+ }
5875
+ return true
5876
+ },
5877
+
5878
+ /**
5879
+ * Run a record-source verb on the record in view. The source keeps its
5880
+ * record in step with the answer, so the header, pills and widgets
5881
+ * redraw from it. A refusal is shown with the server's own message,
5882
+ * which names the verb and the reason.
5883
+ *
5884
+ * @param {{id: string, label: string, successMessage?: string}} verb The verb.
5885
+ * @return {Promise<void>}
5886
+ * @spec openspec/changes/record-source-verbs/specs/record-source-verbs/spec.md#requirement-a-record-source-offers-header-verbs
5887
+ */
5888
+ async runSourceVerb(verb) {
5889
+ const source = this.recordSource
5890
+ const objectId = this.objectId
5891
+ if (!source || !objectId || this.sourceVerbBusy) {
5892
+ return
5893
+ }
5894
+ this.sourceVerbBusy = true
5895
+ try {
5896
+ const url = this.verbEndpoints && typeof this.verbEndpoints[verb.id] === 'string' ? this.verbEndpoints[verb.id] : undefined
5897
+ const outcome = await source.runVerb(String(objectId), verb.id, url ? { url } : {})
5898
+ const { showError, showSuccess } = await import('@nextcloud/dialogs')
5899
+ if (!outcome || outcome.ok !== true) {
5900
+ showError(String(outcome?.message || '').trim() || t('nextcloud-vue', 'This could not be done. Try again.'))
5901
+ return
5902
+ }
5903
+ if (verb.successMessage) {
5904
+ showSuccess(verb.successMessage)
5905
+ }
5906
+ this.$emit('transitioned', { action: verb.id, to: String(outcome.record?.state ?? ''), object: outcome.record || null })
5907
+ } finally {
5908
+ this.sourceVerbBusy = false
5909
+ }
5910
+ },
5911
+
5555
5912
  /**
5556
5913
  * Re-fetch the page's object after a lifecycle transition so the new state
5557
5914
  * (and any other server-side mutations the guard applied) render.
@@ -6328,15 +6685,24 @@ export default {
6328
6685
 
6329
6686
  /**
6330
6687
  * Whether a side entry belongs beside the open body tab: an entry with
6331
- * `tabs` (tab ids) shows only while one of them is open; an entry
6332
- * without shows beside every tab. Edit mode shows them all.
6688
+ * `tabs` (tab ids) shows only while one of them is open; an entry with
6689
+ * `notTabs` stands aside while one of those is open (a tab that draws
6690
+ * the side column as its own, DqZaakAssistent); an entry with neither
6691
+ * shows beside every tab. Edit mode shows them all.
6333
6692
  *
6334
6693
  * @spec openspec/changes/detail-side-cards-per-tab/specs/detail-page/spec.md#requirement-a-side-card-can-follow-the-open-tab
6694
+ * @spec openspec/changes/side-card-not-on-tabs/specs/detail-page/spec.md#requirement-a-side-card-can-stand-aside-for-a-tab
6335
6695
  * @param {object} widget The side entry's definition.
6336
6696
  * @return {boolean}
6337
6697
  */
6338
6698
  sideWidgetOnTab(widget) {
6339
- if (this.editingBody || !Array.isArray(widget.tabs) || widget.tabs.length === 0) {
6699
+ if (this.editingBody) {
6700
+ return true
6701
+ }
6702
+ if (Array.isArray(widget.notTabs) && widget.notTabs.map((id) => String(id)).includes(this.activeBodyTab)) {
6703
+ return false
6704
+ }
6705
+ if (!Array.isArray(widget.tabs) || widget.tabs.length === 0) {
6340
6706
  return true
6341
6707
  }
6342
6708
  return widget.tabs.map((id) => String(id)).includes(this.activeBodyTab)
@@ -6772,7 +7138,8 @@ export default {
6772
7138
  return
6773
7139
  }
6774
7140
  const r = this.resolvedSidebar
6775
- const hasRecord = !this.objectNotFound && !this.awaitingObject
7141
+ // A record held back for its access purpose shows nothing in the sidebar either.
7142
+ const hasRecord = !this.objectNotFound && !this.awaitingObject && !this.contentWithheld
6776
7143
  if (this.sidebarActive && hasRecord && this.resolvedObjectType && this.objectId) {
6777
7144
  const merged = this.mergeSidebarSources(r)
6778
7145
  // Seed `open` only on the inactive→active edge (first activation
@@ -67,6 +67,7 @@
67
67
  <CnWidgetWrapper
68
68
  v-if="missingApp"
69
69
  :title="widgetTitle"
70
+ :subtitle="widgetSubtitle"
70
71
  :chromeless="isBare"
71
72
  titleIconPosition="left"
72
73
  :showRefresh="false"
@@ -207,7 +208,7 @@
207
208
  <CnWidgetWrapper
208
209
  v-else-if="renderer && isContentOnly && !isBare"
209
210
  :title="widget.title || ''"
210
- :subtitle="rendererHeader.subtitle"
211
+ :subtitle="rendererHeader.subtitle || widgetSubtitle"
211
212
  titleIconPosition="left"
212
213
  :showActions="actionsShown || catalogAddEnabled"
213
214
  :showRefresh="false"
@@ -248,6 +249,7 @@
248
249
  <CnWidgetWrapper
249
250
  v-else-if="renderer && isCard && !isBare"
250
251
  :title="widget.title || content.title || ''"
252
+ :subtitle="widgetSubtitle"
251
253
  :showTitle="effectiveShowCardTitle"
252
254
  titleIconPosition="left"
253
255
  flush
@@ -270,7 +272,7 @@
270
272
  <CnWidgetWrapper
271
273
  v-else-if="renderer && wrapsConsumerWidget"
272
274
  :title="widget.title || ''"
273
- :subtitle="rendererHeader.subtitle"
275
+ :subtitle="rendererHeader.subtitle || widgetSubtitle"
274
276
  :showTitle="effectiveShowCardTitle"
275
277
  titleIconPosition="left"
276
278
  :showActions="false"
@@ -754,6 +756,17 @@ export default {
754
756
  return widgetTitleOf(this.widget) || ''
755
757
  },
756
758
 
759
+ /**
760
+ * The muted line under a card's title (`subtitle` on the widget or in
761
+ * its content), translated by the card. Empty when neither sets one.
762
+ *
763
+ * @return {string} The subtitle.
764
+ */
765
+ widgetSubtitle() {
766
+ const subtitle = (this.widget && this.widget.subtitle) || this.content.subtitle || ''
767
+ return typeof subtitle === 'string' ? subtitle : ''
768
+ },
769
+
757
770
  /**
758
771
  * Headline for the set-up state.
759
772
  *