@conduction/nextcloud-vue 2.147.0 → 2.148.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 (49) hide show
  1. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +71 -8
  2. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  3. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +2 -2
  4. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  5. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  6. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +6 -1
  7. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  8. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  9. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  10. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +8 -0
  11. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  12. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  13. package/dist/esm/components/CnStepper/CnStepper.vue.js +1 -1
  14. package/dist/esm/components/CnStepper/CnStepper.vue2.js.map +1 -1
  15. package/dist/esm/components/CnStepper/CnStepper.vue3.js.map +1 -1
  16. package/dist/esm/dialogs/CnAccessPurposeDialog.vue.js +1 -1
  17. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js +3 -1
  18. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js.map +1 -1
  19. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js +26 -15
  20. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js.map +1 -1
  21. package/dist/esm/l10n/en.json.js +2 -1
  22. package/dist/esm/l10n/en.json.js.map +1 -1
  23. package/dist/esm/l10n/nl.json.js +2 -1
  24. package/dist/esm/l10n/nl.json.js.map +1 -1
  25. package/dist/esm/library-style.js +1 -1
  26. package/dist/esm/nextcloud-vue.css +160 -126
  27. package/dist/esm/utils/builtInFormatters.js +53 -1
  28. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  29. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  30. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  31. package/dist/nextcloud-vue.cjs.js +1357 -1217
  32. package/dist/nextcloud-vue.cjs.js.map +2 -2
  33. package/dist/nextcloud-vue.css +160 -126
  34. package/l10n/en.json +2 -1
  35. package/l10n/nl.json +2 -1
  36. package/package.json +1 -1
  37. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  38. package/src/components/CnDetailPage/CnDetailPage.vue +73 -10
  39. package/src/components/CnFactsWidget/CnFactsWidget.vue +6 -1
  40. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  41. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +8 -0
  42. package/src/components/CnStepper/CnStepper.vue +10 -2
  43. package/src/css/dialog.css +5 -2
  44. package/src/css/look-board-index.css +14 -0
  45. package/src/css/look-board.css +3 -0
  46. package/src/dialogs/CnAccessPurposeDialog.vue +27 -12
  47. package/src/schemas/app-manifest-v2.schema.json +4 -2
  48. package/src/utils/builtInFormatters.js +52 -0
  49. package/src/utils/validateManifestV2.compiled.js +3 -3
package/l10n/en.json CHANGED
@@ -2397,7 +2397,8 @@
2397
2397
  "From {date}": "From {date}",
2398
2398
  "Until {date}": "Until {date}",
2399
2399
  "More actions for {name}": "More actions for {name}",
2400
- "Unlink contact": "Unlink contact"
2400
+ "Unlink contact": "Unlink contact",
2401
+ "{term}, extendable by {extension}": "{term}, extendable by {extension}"
2401
2402
  },
2402
2403
  "plurals": {
2403
2404
  "{count} member": [
package/l10n/nl.json CHANGED
@@ -2652,7 +2652,8 @@
2652
2652
  "From {date}": "Vanaf {date}",
2653
2653
  "Until {date}": "Tot {date}",
2654
2654
  "More actions for {name}": "Meer acties voor {name}",
2655
- "Unlink contact": "Contact ontkoppelen"
2655
+ "Unlink contact": "Contact ontkoppelen",
2656
+ "{term}, extendable by {extension}": "{term}, te verdagen met {extension}"
2656
2657
  },
2657
2658
  "plurals": {
2658
2659
  "{count} member": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.147.0",
3
+ "version": "2.148.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": {
@@ -438,7 +438,7 @@ Four levels of action and the case surfaces around them. All opt in. See [the re
438
438
  | `sideColumnFrom` | String | `''` | A field path, read like a `visibleWhen` field (e.g. `@context.caseType.sideCards`), whose list of ids picks the `sideColumn` entries this record shows and their order. Any other value keeps `sideColumn` as declared. |
439
439
  | `tabsFrom` | String | `''` | A field path, read like a `visibleWhen` field, whose list of tab ids picks the tabs of the page's `tabs` widgets and their order for this record: `@context.caseType.tabs` reads the list a case type keeps (DqAanbesteding: Inschrijvingen, Publicaties, Bestanden, Historie). A tab's id is its `id`, else its `widgetId`, or the string entry. An empty or missing list keeps the declared tabs; edit mode shows them all. |
440
440
  | `verbEndpoints` | Object | `null` | App routes that replace a record source's own route for a verb, by verb id (manifest `config.verbEndpoints`), such as `{ "complete": "/apps/dossiq/api/case-tasks/{id}/complete" }` for an app that checks something of its own before the engine completes the task. `{id}` is the record id. Only read with `entitySource`. |
441
- | `accessPurpose` | Object | `null` | Ask for a purpose before the record opens (manifest `config.accessPurpose`): `{ when, options, url, title?, description? }`. `when` is a `visibleWhen` condition on the record and its `visibilityContext` records; `options` are the purposes offered (`{ value, label }`; "Other" with a written purpose is always added); `url` is the app endpoint that records the purpose (POST `{ purpose, note }`, `{objectId}` filled in). Nothing of the record shows until the endpoint accepts, and the same record is not asked again in the same browser session. A refusal keeps the record closed. `null` asks nothing. |
441
+ | `accessPurpose` | Object | `null` | Ask for a purpose before the record opens (manifest `config.accessPurpose`): `{ when, options, url, checkUrl?, title?, description? }`. `when` is a `visibleWhen` condition on the record and its `visibilityContext` records; `options` are the purposes offered (`{ value, label, hint? }`, a `hint` being a line under the option that says what choosing it does; "Other" with a written purpose is always added); `checkUrl` is asked first (GET), and an answer `{ required: false }` opens the record without the dialog, because the app recorded the purpose itself; `url` is the app endpoint that records the purpose (POST `{ purpose, note }`, `{objectId}` filled in). Nothing of the record shows until the endpoint accepts, and the same record is not asked again in the same browser session. A refusal keeps the record closed. `null` asks nothing. |
442
442
  | `isAdmin` | Boolean \| null | `null` | Shows `adminOnly` header actions. `null` reads it from Nextcloud. |
443
443
 
444
444
  ## A body widget's own cards in the side column
@@ -772,7 +772,7 @@
772
772
  the reader has stated why they open it and the purpose is recorded. -->
773
773
  <div v-else-if="contentWithheld" class="cn-detail-page__access-purpose" data-testid="cn-detail-page-access-purpose">
774
774
  <CnAccessPurposeDialog
775
- v-if="accessPurposeAsking"
775
+ v-if="accessPurposeAsking && !accessPurposeChecking"
776
776
  :title="accessPurposeText('title')"
777
777
  :description="accessPurposeText('description')"
778
778
  :options="accessPurposeOptions"
@@ -781,7 +781,7 @@
781
781
  @submit="recordAccessPurpose"
782
782
  @cancel="accessPurposeAsking = false" />
783
783
  <NcEmptyContent
784
- v-else-if="accessPurposeRequired"
784
+ v-else-if="accessPurposeRequired && !accessPurposeChecking"
785
785
  :name="t('nextcloud-vue', 'State a purpose to open this')"
786
786
  :description="t('nextcloud-vue', 'This record holds special categories of personal data. It opens once your purpose is recorded.')"
787
787
  data-testid="cn-detail-page-access-purpose-refused">
@@ -3095,6 +3095,12 @@ export default {
3095
3095
  * browser session. A refusal keeps the record closed. `null` (the
3096
3096
  * default) asks nothing.
3097
3097
  *
3098
+ * `checkUrl` (GET, `{objectId}` filled in) is asked first: an answer
3099
+ * `{ required: false }` opens the record without the dialog (the app
3100
+ * recorded the purpose itself, as for the case's own handler); any
3101
+ * other answer, or a failure, shows the dialog. An option's `hint`
3102
+ * is a line under it saying what choosing it does besides recording it.
3103
+ *
3098
3104
  * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
3099
3105
  * @type {object|null}
3100
3106
  */
@@ -3413,6 +3419,10 @@ export default {
3413
3419
  accessPurposeGranted: readGrantedPurposes(),
3414
3420
  /** Whether the purpose dialog is open; closing it without a purpose keeps the record closed. */
3415
3421
  accessPurposeAsking: true,
3422
+ /** Whether `accessPurpose.checkUrl` is being asked for the record in view. */
3423
+ accessPurposeChecking: false,
3424
+ /** The record the check last answered for, so it is asked once per record. */
3425
+ accessPurposeCheckedKey: '',
3416
3426
  /** Whether a purpose is being recorded. */
3417
3427
  accessPurposeBusy: false,
3418
3428
  /** Why the last purpose was refused. */
@@ -4129,7 +4139,11 @@ export default {
4129
4139
  const options = Array.isArray(this.accessPurpose?.options) ? this.accessPurpose.options : []
4130
4140
  return options
4131
4141
  .filter((option) => option && typeof option.value === 'string' && option.value !== '')
4132
- .map((option) => ({ value: option.value, label: this.cnTranslate(option.label || option.value) }))
4142
+ .map((option) => ({
4143
+ value: option.value,
4144
+ label: this.cnTranslate(option.label || option.value),
4145
+ ...(typeof option.hint === 'string' && option.hint !== '' ? { hint: this.cnTranslate(option.hint) } : {}),
4146
+ }))
4133
4147
  },
4134
4148
 
4135
4149
  /**
@@ -5832,6 +5846,17 @@ export default {
5832
5846
  this.accessPurposeError = ''
5833
5847
  },
5834
5848
 
5849
+ /** Once the page knows the record asks a purpose, the app is asked first whether it must (`checkUrl`). */
5850
+ accessPurposeRequired: {
5851
+ handler(required) {
5852
+ if (required && this.contentWithheld) {
5853
+ this.checkAccessPurpose()
5854
+ }
5855
+ },
5856
+
5857
+ immediate: true,
5858
+ },
5859
+
5835
5860
  // Deep, like `sidebarTabs` / `sidebarProps` below: the manifest editors
5836
5861
  // mutate `page.config.sidebar` IN PLACE, so the reference CnPageRenderer
5837
5862
  // forwards never changes. A shallow watcher missed every such edit —
@@ -6089,6 +6114,50 @@ export default {
6089
6114
  * @param {{purpose: string, note: string}} answer The chosen purpose.
6090
6115
  * @return {Promise<void>}
6091
6116
  */
6117
+ /**
6118
+ * Ask the app whether the reader must state a purpose for this record
6119
+ * (`accessPurpose.checkUrl`); `{ required: false }` opens it at once.
6120
+ *
6121
+ * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
6122
+ * @return {Promise<void>}
6123
+ */
6124
+ async checkAccessPurpose() {
6125
+ const key = this.accessPurposeKey
6126
+ const url = String(this.accessPurpose?.checkUrl || '').replace(/\{objectId\}/g, encodeURIComponent(String(this.objectId || '')))
6127
+ if (url === '' || this.accessPurposeCheckedKey === key) {
6128
+ return
6129
+ }
6130
+ this.accessPurposeCheckedKey = key
6131
+ this.accessPurposeChecking = true
6132
+ try {
6133
+ const { default: axios } = await import('@nextcloud/axios')
6134
+ const { data } = await axios.get(prefixUrl(url))
6135
+ if (data && data.required === false && this.accessPurposeKey === key) {
6136
+ this.grantAccessPurpose(key)
6137
+ }
6138
+ } catch {
6139
+ // The check is a shortcut: without an answer the reader is asked as before.
6140
+ } finally {
6141
+ this.accessPurposeChecking = false
6142
+ }
6143
+ },
6144
+
6145
+ /**
6146
+ * Open the record and remember for this browser session that its
6147
+ * purpose is recorded.
6148
+ *
6149
+ * @param {string} key The record's session key.
6150
+ * @return {void}
6151
+ */
6152
+ grantAccessPurpose(key) {
6153
+ this.accessPurposeGranted = [...new Set([...this.accessPurposeGranted, key])]
6154
+ try {
6155
+ window.sessionStorage.setItem(ACCESS_PURPOSE_SESSION_KEY, JSON.stringify(this.accessPurposeGranted))
6156
+ } catch {
6157
+ // Without session storage the purpose is asked again on the next visit.
6158
+ }
6159
+ },
6160
+
6092
6161
  async recordAccessPurpose(answer) {
6093
6162
  const url = String(this.accessPurpose?.url || '').replace(/\{objectId\}/g, encodeURIComponent(String(this.objectId || '')))
6094
6163
  if (url === '') {
@@ -6100,13 +6169,7 @@ export default {
6100
6169
  try {
6101
6170
  const { default: axios } = await import('@nextcloud/axios')
6102
6171
  await axios.post(prefixUrl(url), { purpose: answer.purpose, note: answer.note })
6103
- const key = this.accessPurposeKey
6104
- this.accessPurposeGranted = [...new Set([...this.accessPurposeGranted, key])]
6105
- try {
6106
- window.sessionStorage.setItem(ACCESS_PURPOSE_SESSION_KEY, JSON.stringify(this.accessPurposeGranted))
6107
- } catch {
6108
- // Without session storage the purpose is asked again on the next visit.
6109
- }
6172
+ this.grantAccessPurpose(this.accessPurposeKey)
6110
6173
  } catch (error) {
6111
6174
  const data = error?.response?.data
6112
6175
  this.accessPurposeError = (data && (data.message || data.error)) || t('nextcloud-vue', 'Your purpose could not be recorded, so the record stays closed.')
@@ -262,6 +262,7 @@ import { useRefLabels } from '../../composables/useRefLabels.js'
262
262
  import { dispatchAction } from '../../utils/actionsDispatcher.js'
263
263
  import { isBadgeVariant } from '../../utils/badgeVariants.js'
264
264
  import { formatBoardDate } from '../../utils/boardDate.js'
265
+ import { formatTermDuration } from '../../utils/builtInFormatters.js'
265
266
  import { conditionalLine } from '../../utils/conditionalLine.js'
266
267
  import { parseDateValue } from '../../utils/dateVariant.js'
267
268
  import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
@@ -485,7 +486,7 @@ export default {
485
486
  * - `headerPill` — a status pill in the card header (reported through the `header` event): `{ path?, text?, map?, variant?, variantMap?, colorMap? }`, read from the record like a `pill` entry (DqBezwaar "Advies ontvangen"); nothing shows while the value is empty.
486
487
  * - `notice` — `{ text, variant? }`, a note band inside the card under the facts (`info` by default; `success`, `warning` or `error`); the text is translated.
487
488
  * - `footnote` — a plain muted line under the facts, without a band (DqZaakBesluiten "Na het ondertekenen ..."); the text is translated.
488
- * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `longdate` (the month written out, the year only outside this year: "3 oktober"), `year` ("2026"), `list` (an array joined by ", ", each item read at `itemPath` and written in `itemFormat`), `group` (a Nextcloud group by its display name), `bytes` (a size, "1.5 MB"), `number` (in the reader's language, `decimals` fraction digits when set), `months` (a number of months in words, whole years as years: 240 is "20 jaar") or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
489
+ * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `longdate` (the month written out, the year only outside this year: "3 oktober"), `duration` (an ISO period in words, "4 weken"; `extendBy` names the extension period on the record: "4 weken, te verdagen met 2"), `year` ("2026"), `list` (an array joined by ", ", each item read at `itemPath` and written in `itemFormat`), `group` (a Nextcloud group by its display name), `bytes` (a size, "1.5 MB"), `number` (in the reader's language, `decimals` fraction digits when set), `months` (a number of months in words, whole years as years: 240 is "20 jaar") or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
489
490
  * `map` turns a raw value into a label (translated). `parts` (on a `line`) lists paths, or `{ path, label?, format? }`, joined by `separator` (default " · "); empty parts are dropped.
490
491
  * - `source` — `{ register, schema, id }` or `{ url }`; omitted = the current object. `{ register, schema, filter, sort? }` without `id` reads the first object that matches `filter` in `sort` order (`{ field, dir }`): the latest count of a shift. An entry with `from: "object"` reads the current object even then (a client card that also shows the ticket's channel).
491
492
  * - `endpointSource` — `{ url, method?, params?, responsePath? }`, the block a stat or chart takes: the facts read an app endpoint, shared and cached like those.
@@ -1893,6 +1894,10 @@ export default {
1893
1894
  const date = parseDateValue(raw)
1894
1895
  return date === null ? '' : String(date.getFullYear())
1895
1896
  }
1897
+ // A term in words, with the extension at `extendBy` on the record: "4 weken, te verdagen met 2".
1898
+ if (format === 'duration') {
1899
+ return formatTermDuration(raw, typeof spec.extendBy === 'string' && record ? readPath(record, spec.extendBy) : null)
1900
+ }
1896
1901
  // The month written out, the year only outside this year: "3 oktober".
1897
1902
  if (format === 'longdate') {
1898
1903
  return formatLongDate(raw)
@@ -121,7 +121,7 @@ export default {
121
121
  | `icon` | Object\|Function | `null` | MDI icon component shown in the card header |
122
122
  | `objectType` | String | `''` | Registered object type slug used by `objectStore.saveObject()` |
123
123
  | `store` | Object | `null` | Explicit Pinia objectStore instance; auto-detected via Pinia when omitted |
124
- | `overrides` | Object | `{}` | Per-property config: `{ key: { order, gridColumn, gridRow, hidden, editable, label, widget, visibleWhen } }`. `visibleWhen` shows the field only when the shared visibility predicate holds for this object, in its local mode: `{ field, op, value }` (ops `eq`, `neq`, `in`, `empty`, ...), or `all` / `any` of those. It reads unsaved changes too. A rule that needs a request (`endpoint`, `source`) is ignored. Read grid only: the Edit form still offers the field. |
124
+ | `overrides` | Object | `{}` | Per-property config: `{ key: { order, gridColumn, gridRow, hidden, editable, label, widget, visibleWhen } }`. `visibleWhen` shows the field only when the shared visibility predicate holds for this object, in its local mode: `{ field, op, value }` (ops `eq`, `neq`, `in`, `empty`, ...), or `all` / `any` of those. It reads unsaved changes too. A rule that needs a request (`endpoint`, `source`) is ignored. Read grid only: the Edit form still offers the field. `format: 'duration'` reads an ISO period in words (`P28D` "4 weken"), and `extendBy` names the object's extension period, read as "4 weken, te verdagen met 2". |
125
125
  | `columns` | Number | `3` | Number of CSS grid columns in the widget |
126
126
  | `collapsedFields` (`collapsed-fields`) | Number | `null` | Number of fields shown while collapsed, with a "Show all N fields" button for the rest. `null` fits the cell: the whole rows that fit stay, and the button only appears when the cell overflows |
127
127
  | `hideEmpty` | Boolean | `false` | Hide fields with no value instead of rendering an em dash. For discriminated supertypes (one `ticket` schema holding request/complaint/contactmoment), so the grid is type-aware without the schema enumerating which fields belong to which variant. Read grid only — the field being edited, a field with unsaved changes, and the Edit form stay visible; `false`/`0` are values and are never hidden. |
@@ -438,10 +438,12 @@ import CnGroupNameCell from '../CnCellRenderer/CnGroupNameCell.vue'
438
438
  import CnFormDialog from '../CnFormDialog/CnFormDialog.vue'
439
439
  import { normalizeLook } from '../../composables/useLook.js'
440
440
  import { useObjectStore } from '../../store/index.js'
441
+ import { formatTermDuration } from '../../utils/builtInFormatters.js'
441
442
  import { foldDottedFields, isDottedKey, spreadDottedFields } from '../../utils/dottedFields.js'
442
443
  import { groupDisplayName, loadGroupDisplayName, searchNextcloudGroups } from '../../utils/groupAutocomplete.js'
443
444
  import { humaniseKey } from '../../utils/humaniseKey.js'
444
445
  import { PANEL_ACTION_SINK } from '../../utils/panelActions.js'
446
+ import { readPath } from '../../utils/readPath.js'
445
447
  import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
446
448
  import { saveEdited } from '../../utils/saveEdited.js'
447
449
  import { fieldsFromSchema, formatValue } from '../../utils/schema.js'
@@ -1103,6 +1105,12 @@ export default {
1103
1105
  // label, beside a list that already read "Ingediend".
1104
1106
  const enumLabel = this.enumDisplayLabel(field, raw)
1105
1107
  const empty = this.emptyValueFor(field, raw)
1108
+ if (empty === null && field.format === 'duration' && raw !== null && raw !== undefined && raw !== '') {
1109
+ // A term in words, with the extension at `extendBy` when the object holds one.
1110
+ const extension = typeof field.extendBy === 'string' ? readPath(this.objectData || {}, field.extendBy) : null
1111
+ values[field.key] = formatTermDuration(raw, extension)
1112
+ continue
1113
+ }
1106
1114
  values[field.key] = empty !== null ? empty : (enumLabel !== null ? enumLabel : formatValue(raw, prop || {}))
1107
1115
  }
1108
1116
  return values
@@ -154,6 +154,14 @@ export default {
154
154
  font: inherit;
155
155
  }
156
156
 
157
+ /* Nextcloud's server.css gives every plain button a 3px margin and the
158
+ clickable-area height (`button:not(.button-vue, …):not(…)`, 0,2,1), which
159
+ made a done step 40px tall around its 28px circle. Two classes outrank it. */
160
+ .cn-stepper button.cn-stepper__step {
161
+ margin: 0;
162
+ min-height: 0;
163
+ }
164
+
157
165
  button.cn-stepper__step {
158
166
  cursor: pointer;
159
167
  }
@@ -230,14 +238,14 @@ button.cn-stepper__step {
230
238
  }
231
239
 
232
240
  /* The board look: one row, 28px circles, the label beside the circle. */
233
- /* The board leaves 20px between the stepper and the step's body (DqInstellen). */
241
+ /* The board leaves 16px between the stepper and the step's body (DqInstellen). */
234
242
  .cn-stepper--board {
235
243
  flex-wrap: wrap;
236
244
  align-items: center;
237
245
  justify-content: flex-start;
238
246
  gap: 8px;
239
247
  padding: 0;
240
- margin: 0 0 20px;
248
+ margin: 0 0 16px;
241
249
  }
242
250
 
243
251
  .cn-stepper--board .cn-stepper__item {
@@ -265,8 +265,11 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .modal-mask {
265
265
  margin-bottom: 4px;
266
266
  }
267
267
 
268
- /* Footer: 16px 24px, hairline on top, right aligned, 40px buttons, 10px gap. */
269
- .cn-dialog-board .dialog__actions {
268
+ /* Footer: 16px 24px, hairline on top, right aligned, 40px buttons, 10px gap,
269
+ flush with the dialog's bottom. NcDialog's own footer rule (6px 0 12px
270
+ margin, 12px end padding) ties a two-class selector and wins when its
271
+ stylesheet loads later, so `.dialog` is added to outrank it. */
272
+ .cn-dialog-board .dialog .dialog__actions {
270
273
  display: flex;
271
274
  flex-wrap: wrap;
272
275
  align-items: center;
@@ -2363,6 +2363,20 @@
2363
2363
  padding-inline-start: 12px;
2364
2364
  }
2365
2365
 
2366
+ /* The boards draw the leading icon inside the title cell, so the title's
2367
+ heading starts over the icon (DqOrganisaties "Naam" at 76, the icon's x),
2368
+ as an avatar shares its column's heading (DqContacten). The blank icon
2369
+ heading stays for the column count; the title heading reaches back over it.
2370
+ @spec openspec/changes/board-head-over-row-icon/specs/data-table/spec.md#requirement-a-board-title-heading-starts-over-the-row-icon */
2371
+ .cn-look-board .cn-table-container--board .cn-data-table th.cn-table-col--icon + th:not(.cn-look-nextcloud *) {
2372
+ padding-inline-start: 0;
2373
+ overflow: visible;
2374
+ }
2375
+
2376
+ .cn-look-board .cn-table-container--board .cn-data-table th.cn-table-col--icon + th:not(.cn-look-nextcloud *) > :first-child {
2377
+ margin-inline-start: -32px;
2378
+ }
2379
+
2366
2380
  /* ---- The table beside the open side panel (DqZakenSorteren, DqFilterBeheren) */
2367
2381
  /* The table takes its column's width: its configured column widths make way
2368
2382
  for the content, titles and names wrap (a long word hyphenated, an
@@ -3220,6 +3220,8 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .v-popper__popper.action-
3220
3220
  Nextcloud's input height stretched it to 13x36. Its weight outranks the
3221
3221
  18px other native radios take.
3222
3222
  @spec openspec/changes/board-setup-choice-radio/specs/board-setup-choice-radio/spec.md#requirement-a-setup-steps-choice-radio-is-20px */
3223
+ /* The board's native radio keeps the browser's 3px end margin before the
3224
+ 12px gap, so the option title starts 56px in (DqInstellen), not 53. */
3223
3225
  .cn-look-board .cn-setup-step .cn-choice-cards input.cn-choice-cards__row-input:not(.cn-look-nextcloud *) {
3224
3226
  box-sizing: border-box;
3225
3227
  width: 20px;
@@ -3227,6 +3229,7 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .v-popper__popper.action-
3227
3229
  height: 20px;
3228
3230
  min-height: 20px;
3229
3231
  accent-color: var(--color-primary-element);
3232
+ margin-inline-end: 3px;
3230
3233
  }
3231
3234
 
3232
3235
  /* The chosen setup choice is edged by a real 2px border, as DqInstellen
@@ -17,17 +17,23 @@
17
17
  <legend class="cn-access-purpose__legend">
18
18
  {{ t('nextcloud-vue', 'Purpose') }}
19
19
  </legend>
20
- <NcCheckboxRadioSwitch
21
- v-for="option in choices"
22
- :key="option.value"
23
- v-model="purpose"
24
- type="radio"
25
- name="cn-access-purpose"
26
- :value="option.value"
27
- :disabled="loading"
28
- :data-testid="`cn-access-purpose-option-${option.value}`">
29
- {{ option.label }}
30
- </NcCheckboxRadioSwitch>
20
+ <template v-for="option in choices" :key="option.value">
21
+ <NcCheckboxRadioSwitch
22
+ v-model="purpose"
23
+ type="radio"
24
+ name="cn-access-purpose"
25
+ :value="option.value"
26
+ :disabled="loading"
27
+ :data-testid="`cn-access-purpose-option-${option.value}`">
28
+ {{ option.label }}
29
+ </NcCheckboxRadioSwitch>
30
+ <!-- What choosing this purpose does besides recording it ("you become this case's handler"). -->
31
+ <p v-if="option.hint"
32
+ class="cn-access-purpose__hint"
33
+ :data-testid="`cn-access-purpose-hint-${option.value}`">
34
+ {{ option.hint }}
35
+ </p>
36
+ </template>
31
37
  </fieldset>
32
38
  <NcTextArea
33
39
  v-if="purpose === OTHER"
@@ -94,8 +100,10 @@ export default {
94
100
 
95
101
  /**
96
102
  * The purposes offered, already translated. "Other" is added at the end.
103
+ * A `hint` is a line under the option that says what choosing it does
104
+ * besides recording it.
97
105
  *
98
- * @type {Array<{value: string, label: string}>}
106
+ * @type {Array<{value: string, label: string, hint?: string}>}
99
107
  */
100
108
  options: {
101
109
  type: Array,
@@ -176,6 +184,13 @@ export default {
176
184
  border: 0;
177
185
  }
178
186
 
187
+ .cn-access-purpose__hint {
188
+ margin: 0 0 6px;
189
+ padding-inline-start: 36px;
190
+ color: var(--color-text-maxcontrast);
191
+ font-size: 13px;
192
+ }
193
+
179
194
  .cn-access-purpose__legend {
180
195
  margin-bottom: 4px;
181
196
  font-weight: 600;
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.156.0",
6
+ "version": "2.157.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -5811,11 +5811,13 @@
5811
5811
  "additionalProperties": false,
5812
5812
  "properties": {
5813
5813
  "value": { "type": "string", "minLength": 1 },
5814
- "label": { "type": "string", "minLength": 1, "description": "i18n key." }
5814
+ "label": { "type": "string", "minLength": 1, "description": "i18n key." },
5815
+ "hint": { "type": "string", "description": "i18n key of a line under the option: what choosing it does besides recording it." }
5815
5816
  }
5816
5817
  }
5817
5818
  },
5818
5819
  "url": { "type": "string", "minLength": 1, "description": "App endpoint that records the purpose; {objectId} is filled in." },
5820
+ "checkUrl": { "type": "string", "minLength": 1, "description": "App endpoint asked first (GET, {objectId} filled in): an answer { required: false } opens the record without the dialog, because the app recorded the purpose itself." },
5819
5821
  "title": { "type": "string", "description": "i18n key of the dialog's title." },
5820
5822
  "description": { "type": "string", "description": "i18n key of the sentence under the title." }
5821
5823
  }
@@ -524,6 +524,57 @@ export function formatIsoDuration(value) {
524
524
  return words[period.unit] ? words[period.unit]() : String(value)
525
525
  }
526
526
 
527
+ /** The ISO designator of each date unit a term is written in. */
528
+ const UNIT_DESIGNATOR = { years: 'Y', months: 'M', weeks: 'W', days: 'D' }
529
+
530
+ /**
531
+ * A one-unit ISO-8601 period, a day count that is whole weeks read as weeks
532
+ * (a term of `P28D` is "4 weeks"); null when it is not a one-unit period.
533
+ *
534
+ * @param {unknown} value The stored period.
535
+ * @return {{amount: number, unit: string}|null}
536
+ */
537
+ function termPeriod(value) {
538
+ if (value === null || value === undefined || value === '') {
539
+ return null
540
+ }
541
+ const period = parseDuration(String(value))
542
+ if (period === null || !UNIT_DESIGNATOR[period.unit]) {
543
+ return null
544
+ }
545
+ if (period.unit === 'days' && period.amount > 0 && period.amount % 7 === 0) {
546
+ return { amount: period.amount / 7, unit: 'weeks' }
547
+ }
548
+ return period
549
+ }
550
+
551
+ /**
552
+ * A term in words, with the extension it may get (`format: 'duration'`):
553
+ * `P28D` reads "4 weeks", and with an extension of `P14D` "4 weeks,
554
+ * extendable by 2" (the unit is said once when both share it). A value that
555
+ * is not a one-unit period is returned as written, '' for null or empty.
556
+ *
557
+ * @spec openspec/changes/data-widget-duration-format/specs/data-widget/spec.md#requirement-a-term-reads-in-words-with-its-extension
558
+ * @param {unknown} value The stored term.
559
+ * @param {unknown} [extension] The stored extension period, if any.
560
+ * @return {string}
561
+ */
562
+ export function formatTermDuration(value, extension = null) {
563
+ const term = termPeriod(value)
564
+ if (term === null) {
565
+ return value === null || value === undefined ? '' : String(value)
566
+ }
567
+ const words = formatIsoDuration(`P${term.amount}${UNIT_DESIGNATOR[term.unit]}`)
568
+ const extra = termPeriod(extension)
569
+ if (extra === null || extra.amount === 0) {
570
+ return words
571
+ }
572
+ const extraWords = extra.unit === term.unit
573
+ ? String(extra.amount)
574
+ : formatIsoDuration(`P${extra.amount}${UNIT_DESIGNATOR[extra.unit]}`)
575
+ return t('nextcloud-vue', '{term}, extendable by {extension}', { term: words, extension: extraWords })
576
+ }
577
+
527
578
  /**
528
579
  * A Nextcloud user id as its display name (`userName` registry key): the id
529
580
  * while the name is looked up, and for a user nobody answers for.
@@ -553,6 +604,7 @@ export const BUILT_IN_FORMATTERS = {
553
604
  connectionSettingsLabel: formatConnectionSettingsLabel,
554
605
  masked: formatMasked,
555
606
  isoDuration: formatIsoDuration,
607
+ termDuration: formatTermDuration,
556
608
  valueMap: formatValueMap,
557
609
  userName: formatUserName,
558
610
  }