@conduction/nextcloud-vue 2.146.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 (58) 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 +73 -9
  7. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  8. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +37 -13
  9. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  10. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  11. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +8 -0
  12. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  14. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue.js +1 -1
  15. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js +2 -2
  16. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js.map +1 -1
  18. package/dist/esm/components/CnStepper/CnStepper.vue.js +1 -1
  19. package/dist/esm/components/CnStepper/CnStepper.vue2.js.map +1 -1
  20. package/dist/esm/components/CnStepper/CnStepper.vue3.js.map +1 -1
  21. package/dist/esm/dialogs/CnAccessPurposeDialog.vue.js +1 -1
  22. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js +3 -1
  23. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js.map +1 -1
  24. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js +26 -15
  25. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js.map +1 -1
  26. package/dist/esm/l10n/en.json.js +2 -1
  27. package/dist/esm/l10n/en.json.js.map +1 -1
  28. package/dist/esm/l10n/nl.json.js +2 -1
  29. package/dist/esm/l10n/nl.json.js.map +1 -1
  30. package/dist/esm/library-style.js +1 -1
  31. package/dist/esm/nextcloud-vue.css +191 -145
  32. package/dist/esm/utils/builtInFormatters.js +53 -1
  33. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  34. package/dist/esm/utils/objectParties.js +30 -1
  35. package/dist/esm/utils/objectParties.js.map +1 -1
  36. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  37. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  38. package/dist/nextcloud-vue.cjs.js +1548 -1296
  39. package/dist/nextcloud-vue.cjs.js.map +2 -2
  40. package/dist/nextcloud-vue.css +191 -145
  41. package/l10n/en.json +2 -1
  42. package/l10n/nl.json +2 -1
  43. package/package.json +1 -1
  44. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  45. package/src/components/CnDetailPage/CnDetailPage.vue +73 -10
  46. package/src/components/CnFactsWidget/CnFactsWidget.vue +101 -9
  47. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  48. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +8 -0
  49. package/src/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue +2 -1
  50. package/src/components/CnStepper/CnStepper.vue +10 -2
  51. package/src/css/dialog.css +5 -2
  52. package/src/css/look-board-index.css +14 -0
  53. package/src/css/look-board.css +3 -0
  54. package/src/dialogs/CnAccessPurposeDialog.vue +27 -12
  55. package/src/schemas/app-manifest-v2.schema.json +4 -2
  56. package/src/utils/builtInFormatters.js +52 -0
  57. package/src/utils/objectParties.js +29 -0
  58. 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.146.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.')
@@ -175,6 +175,15 @@
175
175
  </span>
176
176
  <span class="cn-facts-widget__line">{{ block.value }}</span>
177
177
  </div>
178
+ <div v-else-if="block.kind === 'pills'" class="cn-facts-widget__pills">
179
+ <CnStatusBadge
180
+ v-for="pill in block.pills"
181
+ :key="pill.key"
182
+ :label="pill.value"
183
+ :variant="pill.variant"
184
+ size="small"
185
+ :data-testid="`cn-fact-${pill.key}`" />
186
+ </div>
178
187
  <div v-else-if="block.kind === 'pill'">
179
188
  <CnStatusBadge
180
189
  :label="block.value"
@@ -191,6 +200,12 @@
191
200
  data-testid="cn-facts-notice">
192
201
  {{ notice.text }}
193
202
  </NcNoteCard>
203
+ <div
204
+ v-if="footnote && record"
205
+ class="cn-facts-widget__footnote"
206
+ data-testid="cn-facts-footnote">
207
+ {{ footnote }}
208
+ </div>
194
209
  <button
195
210
  v-if="link && link.sidebarTab && record"
196
211
  type="button"
@@ -247,6 +262,7 @@ import { useRefLabels } from '../../composables/useRefLabels.js'
247
262
  import { dispatchAction } from '../../utils/actionsDispatcher.js'
248
263
  import { isBadgeVariant } from '../../utils/badgeVariants.js'
249
264
  import { formatBoardDate } from '../../utils/boardDate.js'
265
+ import { formatTermDuration } from '../../utils/builtInFormatters.js'
250
266
  import { conditionalLine } from '../../utils/conditionalLine.js'
251
267
  import { parseDateValue } from '../../utils/dateVariant.js'
252
268
  import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
@@ -296,6 +312,30 @@ function resolveSourceTokens(value, record) {
296
312
  }
297
313
 
298
314
  /** Kinds of entry a `fields[]` item can be. A labelled item without `as` is a row. */
315
+ /**
316
+ * Two or more pills in a row become one `pills` block, drawn on one line.
317
+ *
318
+ * @param {Array<object>} blocks The blocks in order.
319
+ * @return {Array<object>}
320
+ * @spec openspec/changes/facts-fields-from-context/specs/facts-widget/spec.md#requirement-a-facts-card-can-take-its-entries-from-the-page-context
321
+ */
322
+ function groupPills(blocks) {
323
+ const out = []
324
+ blocks.forEach((block) => {
325
+ const last = out[out.length - 1]
326
+ if (block.kind !== 'pill') {
327
+ out.push(block)
328
+ } else if (last && last.kind === 'pills') {
329
+ last.pills.push(block)
330
+ } else if (last && last.kind === 'pill') {
331
+ out[out.length - 1] = { kind: 'pills', key: `pills-${last.key}`, pills: [last, block] }
332
+ } else {
333
+ out.push(block)
334
+ }
335
+ })
336
+ return out
337
+ }
338
+
299
339
  const KINDS = Object.freeze(['row', 'title', 'line', 'pill', 'lead', 'text', 'meter', 'progress'])
300
340
 
301
341
  /** The schemes a facts link opens, besides a path on this server. */
@@ -436,15 +476,17 @@ export default {
436
476
  * `resolve: { register, schema, labelField? }` on an entry or a part turns a value that is a reference (an id, or a list of ids) into the label of the referenced object (`labelField`, else `title`, then `name`): the case type or status of a task shows its name, not its uuid. The ids are fetched once per register and schema through the object store and shared by every facts widget on the page; the entry stays empty until its label is known, and when no label can be read. A resolved value works as a pill too: `map` is applied to the resolved label, and `variantMap` / `colorMap` look the tone up by the value of `resolve.toneField` on the referenced object when that is set (a status named by its `name`, coloured by its `state`), then by the resolved label, then by the stored value.
437
477
  * `route` (`{ name, params?, query? }`, `@source.<path>` in a param) on a `title` or a `row` links its value to that route; a param that does not fill leaves it plain.
438
478
  * `format: 'user'` shows a Nextcloud user id as the user's display name, read once per id; the entry stays empty until the name is known.
439
- * `template` ("{available} {unit}") fills its `{path}` placeholders from the record instead of reading `path`; it is empty when every placeholder is. `suffix` (" kg") is added to a value that is not empty, its words translated.
479
+ * `template` ("{available} {unit}") fills its `{path}` placeholders from the record instead of reading `path` (`{path:format}` formats one, "{from:year}"); it is empty when every placeholder is. `suffix` (" kg") is added to a value that is not empty, its words translated.
440
480
  * `labelPath` ("@context.caseType.handlerLabel") names a value on the record or the page's `@context` records that labels the row when it holds text; `label` is the fallback.
441
481
  * `emptyValue` ("No") is shown, translated, when the entry has no value, so the entry stays drawn whatever `hideEmpty` says (an override that was never set reads "No").
442
482
  * - `action` — an action drawn as a button under the facts; `headerAction` — one drawn in the card header (reported through the `header` event).
443
483
  * - `actions` — action buttons under the facts in the `headerActions` shape (`api-call`, `open-form`, `navigate`, `open-modal`, ...; `confirm`, `variant`). `@source.<field>` names a field of the record shown, and an action whose token names an empty field is not drawn. `visibleWhen` reads the record shown; `adminOnly: true` draws the action for an instance admin only (visibility, never authorisation). An api-call or a saved form refreshes the page, and the facts read their source again.
484
+ * - `fieldsFrom` — `@context.<name>.<path>`, a list of entries on a record the page reads beside this one (a case type's `requestFields`); when it holds entries they are drawn instead of `fields`.
444
485
  * - `rowsPath` — a path to a list of `{ label, value }` the record holds, drawn as rows after `fields` (a preview the server builds).
445
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.
446
487
  * - `notice` — `{ text, variant? }`, a note band inside the card under the facts (`info` by default; `success`, `warning` or `error`); the text is translated.
447
- * `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"), `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).
488
+ * - `footnote` — a plain muted line under the facts, without a band (DqZaakBesluiten "Na het ondertekenen ..."); the text is translated.
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).
448
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.
449
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).
450
492
  * - `endpointSource` — `{ url, method?, params?, responsePath? }`, the block a stat or chart takes: the facts read an app endpoint, shared and cached like those.
@@ -503,6 +545,29 @@ export default {
503
545
  },
504
546
 
505
547
  computed: {
548
+ /**
549
+ * The entries to draw: the list `fieldsFrom` names on the page's
550
+ * `@context` records when it holds entries (a case type's
551
+ * `requestFields`), else `fields`.
552
+ *
553
+ * @return {Array<object>}
554
+ * @spec openspec/changes/facts-fields-from-context/specs/facts-widget/spec.md#requirement-a-facts-card-can-take-its-entries-from-the-page-context
555
+ */
556
+ entries() {
557
+ const from = this.content?.fieldsFrom
558
+ if (typeof from === 'string' && from.startsWith('@context.')) {
559
+ const rows = this.cnDetailVisibilityRows && typeof this.cnDetailVisibilityRows === 'object' && 'value' in this.cnDetailVisibilityRows
560
+ ? this.cnDetailVisibilityRows.value
561
+ : this.cnDetailVisibilityRows
562
+ const named = readPath({ '@context': rows || {} }, from)
563
+ const list = Array.isArray(named) ? named.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry)) : []
564
+ if (list.length > 0) {
565
+ return list
566
+ }
567
+ }
568
+ return Array.isArray(this.content?.fields) ? this.content.fields : []
569
+ },
570
+
506
571
  /**
507
572
  * Whether the rows sit in two columns, label above value.
508
573
  *
@@ -547,7 +612,7 @@ export default {
547
612
  * @spec openspec/changes/facts-entry-count/specs/facts-widget/spec.md#requirement-a-facts-entry-can-count-a-related-list
548
613
  */
549
614
  countJobsKey() {
550
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
615
+ const fields = this.entries
551
616
  const keys = fields
552
617
  .filter((field) => field && field.countFrom && typeof field.countFrom === 'object' && field.countFrom.register && field.countFrom.schema)
553
618
  .map((field) => this.countKey(field.countFrom))
@@ -708,7 +773,7 @@ export default {
708
773
  */
709
774
  blocks() {
710
775
  const sourceRecord = this.record
711
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
776
+ const fields = this.entries
712
777
  // A source that finds nothing still leaves the entries that read the
713
778
  // page's own object (`from: "object"`).
714
779
  const ownEntries = Boolean(this.objectData) && fields.some((field) => field && field.from === 'object')
@@ -787,7 +852,7 @@ export default {
787
852
  out.push({ kind: 'rows', key: 'rows-answered', rows: [row] })
788
853
  }
789
854
  })
790
- return out
855
+ return groupPills(out)
791
856
  },
792
857
 
793
858
  /**
@@ -879,6 +944,12 @@ export default {
879
944
  return { text, type: ['success', 'warning', 'error'].includes(notice.variant) ? notice.variant : 'info' }
880
945
  },
881
946
 
947
+ /** @return {string} The translated footnote, or '' when none is set. */
948
+ footnote() {
949
+ const footnote = this.content?.footnote
950
+ return typeof footnote === 'string' && footnote.trim() !== '' ? this.tr(footnote) : ''
951
+ },
952
+
882
953
  /** @return {string} The reveal button's text. */
883
954
  showLabel() {
884
955
  return t('nextcloud-vue', 'Show')
@@ -1116,7 +1187,7 @@ export default {
1116
1187
  * @spec openspec/changes/facts-entry-count/specs/facts-widget/spec.md#requirement-a-facts-entry-can-count-a-related-list
1117
1188
  */
1118
1189
  async loadCounts() {
1119
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
1190
+ const fields = this.entries
1120
1191
  const seen = new Set()
1121
1192
  for (const field of fields) {
1122
1193
  const countFrom = field && field.countFrom
@@ -1193,8 +1264,8 @@ export default {
1193
1264
  */
1194
1265
  templateValue(record, template) {
1195
1266
  let filled = false
1196
- const text = this.tr(template).replace(/\{([\w.@]+)\}/g, (match, path) => {
1197
- const value = this.format(readPath(record, path), {}, record)
1267
+ const text = this.tr(template).replace(/\{([\w.@]+)(?::([\w-]+))?\}/g, (match, path, format) => {
1268
+ const value = this.format(readPath(record, path), format ? { format } : {}, record)
1198
1269
  filled = filled || value !== ''
1199
1270
  return value
1200
1271
  })
@@ -1586,7 +1657,7 @@ export default {
1586
1657
  * @return {Array<{spec: object, raw: unknown}>}
1587
1658
  */
1588
1659
  refSpecs(record) {
1589
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
1660
+ const fields = this.entries
1590
1661
  const specs = []
1591
1662
  const visit = (spec) => {
1592
1663
  if (!spec || typeof spec !== 'object') {
@@ -1819,6 +1890,14 @@ export default {
1819
1890
  .filter((item) => item !== '')
1820
1891
  return items.join(', ')
1821
1892
  }
1893
+ if (format === 'year') {
1894
+ const date = parseDateValue(raw)
1895
+ return date === null ? '' : String(date.getFullYear())
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
+ }
1822
1901
  // The month written out, the year only outside this year: "3 oktober".
1823
1902
  if (format === 'longdate') {
1824
1903
  return formatLongDate(raw)
@@ -1960,6 +2039,12 @@ export default {
1960
2039
  min-width: 0;
1961
2040
  }
1962
2041
 
2042
+ .cn-facts-widget__pills {
2043
+ display: flex;
2044
+ flex-wrap: wrap;
2045
+ gap: 8px;
2046
+ }
2047
+
1963
2048
  /* `padding: 0`: Nextcloud's core stylesheet pads every `dl` 12px at the top
1964
2049
  and bottom, which put a side card's facts 24px under its title, against
1965
2050
  the board's 12 (DqZaak "Behandeling", DqZaakDocumenten). */
@@ -2216,6 +2301,13 @@ export default {
2216
2301
  margin: 4px 0 0;
2217
2302
  }
2218
2303
 
2304
+ /* A div, not a p: a theme sets every paragraph's size with !important. */
2305
+ .cn-facts-widget__footnote {
2306
+ color: var(--color-text-maxcontrast);
2307
+ font-size: 13px;
2308
+ line-height: 1.2;
2309
+ }
2310
+
2219
2311
  .cn-facts-widget__link {
2220
2312
  font-size: 14px;
2221
2313
  font-weight: 600;
@@ -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
@@ -248,6 +248,7 @@ import {
248
248
  indicatorVerdict,
249
249
  kindCode,
250
250
  newPartyRecord,
251
+ partyKindAccepted,
251
252
  representedByMap,
252
253
  roleLabel,
253
254
  rolesInOrder,
@@ -892,7 +893,7 @@ export default {
892
893
  if (!party?.partyKind || !this.kinds || (enforce && this.kinds.source !== enforce)) {
893
894
  return false
894
895
  }
895
- return !this.kinds.kinds.some((kind) => kindCode(kind) === party.partyKind)
896
+ return !partyKindAccepted(party.partyKind, this.kinds.kinds, this.listing?.kinds)
896
897
  },
897
898
 
898
899
  /**
@@ -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
  }