@conduction/nextcloud-vue 2.82.0 → 2.84.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 (178) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +2 -2
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  4. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +24 -0
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  6. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +9 -4
  7. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  8. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +1 -1
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnBodySections/CnBodySections.vue.js +1 -1
  13. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js +22 -1
  14. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js.map +1 -1
  15. package/dist/esm/components/CnBodySections/CnBodySections.vue3.js.map +1 -1
  16. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
  17. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +47 -4
  18. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
  19. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
  20. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue.js +1 -1
  21. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +125 -3
  22. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +3 -3
  24. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +14 -0
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue.js +1 -1
  30. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js +1 -1
  31. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js.map +1 -1
  32. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +203 -4
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  37. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +38 -1
  38. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +45 -5
  40. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  41. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue.js +1 -1
  42. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js +1 -1
  43. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue3.js.map +1 -1
  45. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +96 -12
  46. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  47. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +95 -50
  48. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  49. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js +29 -6
  50. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js.map +1 -1
  51. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue3.js.map +1 -1
  52. package/dist/esm/components/CnMassActionBar/CnMassActionBar.vue2.js +2 -2
  53. package/dist/esm/components/CnMassActionBar/CnMassActionBar.vue2.js.map +1 -1
  54. package/dist/esm/components/CnMassImportDialog/CnMassImportDialog.vue2.js +2 -2
  55. package/dist/esm/components/CnMassImportDialog/CnMassImportDialog.vue2.js.map +1 -1
  56. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  57. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +31 -2
  58. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  60. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue.js +1 -1
  61. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js +16 -2
  62. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js +3 -3
  64. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js.map +1 -1
  65. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  66. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +37 -2
  67. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  68. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  69. package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
  70. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +176 -0
  71. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  72. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +181 -52
  73. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  74. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue.js +1 -1
  75. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js +70 -5
  76. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js.map +1 -1
  77. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js +61 -35
  78. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js.map +1 -1
  79. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue.js +1 -1
  80. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +72 -5
  81. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  82. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +33 -8
  83. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  84. package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
  85. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +4 -2
  86. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  87. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  88. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue.js +1 -1
  89. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +72 -5
  90. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  91. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +88 -71
  92. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  93. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue.js +1 -1
  94. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js +18 -4
  95. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js.map +1 -1
  96. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue3.js.map +1 -1
  97. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  98. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -3
  99. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  100. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  101. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue.js +1 -1
  102. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +16 -0
  103. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  104. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  105. package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
  106. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +68 -6
  107. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
  108. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +133 -105
  109. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
  110. package/dist/esm/l10n/en.json.js +16 -1
  111. package/dist/esm/l10n/en.json.js.map +1 -1
  112. package/dist/esm/l10n/nl.json.js +41 -4
  113. package/dist/esm/l10n/nl.json.js.map +1 -1
  114. package/dist/esm/library-style.js +1 -1
  115. package/dist/esm/nextcloud-vue.css +900 -537
  116. package/dist/esm/utils/buildManifest.js +37 -3
  117. package/dist/esm/utils/buildManifest.js.map +1 -1
  118. package/dist/esm/utils/detailWidgetVisibility.js +149 -0
  119. package/dist/esm/utils/detailWidgetVisibility.js.map +1 -0
  120. package/dist/esm/utils/fetchAggregate.js +25 -2
  121. package/dist/esm/utils/fetchAggregate.js.map +1 -1
  122. package/dist/esm/utils/formatMetric.js +39 -3
  123. package/dist/esm/utils/formatMetric.js.map +1 -1
  124. package/dist/esm/utils/resolveManifestTokens.js +1 -0
  125. package/dist/esm/utils/resolveManifestTokens.js.map +1 -1
  126. package/dist/esm/utils/sentinelTokens.js +9 -0
  127. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  128. package/dist/esm/utils/switchValues.js +62 -0
  129. package/dist/esm/utils/switchValues.js.map +1 -0
  130. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  131. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  132. package/dist/nextcloud-vue.cjs.js +36711 -34909
  133. package/dist/nextcloud-vue.cjs.js.map +2 -2
  134. package/dist/nextcloud-vue.css +900 -537
  135. package/l10n/en.json +16 -1
  136. package/l10n/nl.json +41 -4
  137. package/package.json +1 -1
  138. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +31 -2
  139. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +1 -1
  140. package/src/components/CnBodySections/CnBodySections.vue +22 -1
  141. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +47 -4
  142. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +128 -6
  143. package/src/components/CnDashboardPage/CnDashboardPage.vue +14 -0
  144. package/src/components/CnDeltaWidget/CnDeltaWidget.vue +1 -1
  145. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  146. package/src/components/CnDetailPage/CnDetailPage.vue +203 -4
  147. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +63 -1
  148. package/src/components/CnGaugeWidget/CnGaugeWidget.vue +1 -1
  149. package/src/components/CnIndexPage/CnIndexPage.vue +125 -15
  150. package/src/components/CnInteractionFormWidget/CnInteractionFormWidget.vue +29 -6
  151. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  152. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +31 -2
  153. package/src/components/CnRadioCardsWidget/CnRadioCardsWidget.vue +54 -3
  154. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +37 -2
  155. package/src/components/CnReportsPage/CnReportsPage.vue +263 -5
  156. package/src/components/CnSettingsFormWidget/CnSettingsFormWidget.vue +110 -18
  157. package/src/components/CnStagesWidget/CnStagesWidget.vue +103 -6
  158. package/src/components/CnStatWidget/CnStatWidget.vue +4 -2
  159. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +125 -7
  160. package/src/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue +18 -4
  161. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -3
  162. package/src/components/CnWalkthrough/CnWalkthrough.vue +16 -0
  163. package/src/css/form-field.css +14 -0
  164. package/src/css/form-widget.css +11 -0
  165. package/src/css/index-page.css +11 -0
  166. package/src/css/index.css +1 -0
  167. package/src/css/look-board-reports.css +175 -0
  168. package/src/css/look-board.css +44 -1
  169. package/src/dialogs/CnTransitionInputDialog.vue +87 -7
  170. package/src/schemas/app-manifest-v2.schema.json +24 -3
  171. package/src/utils/buildManifest.js +36 -2
  172. package/src/utils/detailWidgetVisibility.js +145 -0
  173. package/src/utils/fetchAggregate.js +24 -1
  174. package/src/utils/formatMetric.js +38 -2
  175. package/src/utils/resolveManifestTokens.js +1 -0
  176. package/src/utils/sentinelTokens.js +9 -0
  177. package/src/utils/switchValues.js +59 -0
  178. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -32,6 +32,7 @@ import { NcCheckboxRadioSwitch, NcNoteCard } from '@nextcloud/vue'
32
32
  import { objectUrlOf, writeFields } from '../../utils/objectWrite.js'
33
33
  import { readPath } from '../../utils/readPath.js'
34
34
  import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
35
+ import { switchIsOn, switchStoredValue } from '../../utils/switchValues.js'
35
36
 
36
37
  /**
37
38
  * CnSwitchRowsWidget — the `switch-rows` widget: a card of switches, each with
@@ -54,7 +55,8 @@ export default {
54
55
  /**
55
56
  * The widget configuration.
56
57
  *
57
- * - `rows` (required) — `[{ key, label, hint?, invert? }]`; `key` is a boolean field, a top-level name or a dot-path. `invert` shows the switch on when the field is false (a stored `disabled` read as "enabled").
58
+ * - `rows` (required) — `[{ key, label, hint?, invert?, values? }]`; `key` is a boolean field, a top-level name or a dot-path. `invert` shows the switch on when the field is false (a stored `disabled` read as "enabled"). `values: { on, off }` reads and writes those values instead of true/false (an app config flag stored as `"1"`/`"0"`).
59
+ * - `values` — `{ on, off }` for every row that declares none.
58
60
  * - `save` — `{ url, method?, payload? }`, write through an app endpoint (tokens `{value}`, `{field}`, `{changes}`, `{object.<path>}`).
59
61
  * - `readOnly`.
60
62
  *
@@ -137,11 +139,22 @@ export default {
137
139
  return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
138
140
  },
139
141
 
142
+ /**
143
+ * A row's value map: its own `values`, else the widget's.
144
+ *
145
+ * @spec openspec/changes/screens-switch-value-map/specs/switch-rows/spec.md#requirement-a-switch-can-read-and-write-a-value-map
146
+ * @param {object} row The row.
147
+ * @return {object|undefined} The declared `values`.
148
+ */
149
+ valuesOf(row) {
150
+ return row.values !== undefined ? row.values : this.content.values
151
+ },
152
+
140
153
  /** Read every switch off the object. */
141
154
  adopt() {
142
155
  const values = {}
143
156
  for (const row of this.rows) {
144
- const stored = readPath(this.objectData || {}, row.key) === true
157
+ const stored = switchIsOn(readPath(this.objectData || {}, row.key), this.valuesOf(row))
145
158
  values[row.key] = row.invert ? !stored : stored
146
159
  }
147
160
  this.saved = values
@@ -165,10 +178,11 @@ export default {
165
178
  this.busy = row.key
166
179
  this.values = { ...this.values, [row.key]: on }
167
180
  const stored = row.invert ? !on : on
181
+ const values = this.valuesOf(row)
168
182
  try {
169
- const data = await writeFields(this.objectUrl, { [row.key]: stored }, { save: this.content.save, object: this.objectData })
183
+ const data = await writeFields(this.objectUrl, { [row.key]: switchStoredValue(stored, values) }, { save: this.content.save, object: this.objectData })
170
184
  const answered = readPath(data, row.key)
171
- const written = answered !== undefined ? answered === true : stored
185
+ const written = answered !== undefined ? switchIsOn(answered, values) : stored
172
186
  const shown = row.invert ? !written : written
173
187
  this.saved = { ...this.saved, [row.key]: shown }
174
188
  this.values = { ...this.values, [row.key]: shown }
@@ -311,6 +311,12 @@ export default {
311
311
  cnLook: { default: 'nextcloud' },
312
312
  /** The tab list's name under the board look, provided by CnDetailPage (`config.tabsLabel`). */
313
313
  cnDetailTabsLabel: { default: null },
314
+ /**
315
+ * The records the detail page's `visibilityContext` names, keyed
316
+ * by name (a case's case type), provided by CnDetailPage. A tab's
317
+ * `visibleWhen` reads them as `@context.<name>`. Null on any other page.
318
+ */
319
+ cnDetailVisibilityRows: { default: null },
314
320
  /**
315
321
  * The host's translate (CnAppRoot provides `cnTranslate`), so a tab
316
322
  * label written in the manifest reads in the user's language.
@@ -475,6 +481,29 @@ export default {
475
481
  },
476
482
 
477
483
  computed: {
484
+ /**
485
+ * What a tab's `visibleWhen` reads: the record, with the records the
486
+ * detail page's `visibilityContext` names under `@context.<name>`. Null while
487
+ * the record, or one of those records, is not there yet.
488
+ *
489
+ * @return {object|null}
490
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
491
+ */
492
+ conditionData() {
493
+ if (!this.objectData) {
494
+ return null
495
+ }
496
+ const holder = this.cnDetailVisibilityRows
497
+ if (!holder) {
498
+ return this.objectData
499
+ }
500
+ const rows = holder.value
501
+ if (!rows) {
502
+ return null
503
+ }
504
+ return { ...this.objectData, '@context': rows }
505
+ },
506
+
478
507
  /**
479
508
  * The open panel's own menu items, or an empty list.
480
509
  *
@@ -859,10 +888,11 @@ export default {
859
888
  return 'visible'
860
889
  }
861
890
  if (isLocallyDecidableVisibleWhen(cond)) {
862
- if (!this.objectData) {
891
+ const data = this.conditionData
892
+ if (!data) {
863
893
  return 'pending'
864
894
  }
865
- return evaluateVisibleWhenLocal(cond, this.objectData) ? 'visible' : 'hidden'
895
+ return evaluateVisibleWhenLocal(cond, data) ? 'visible' : 'hidden'
866
896
  }
867
897
  const answer = this.sourceVisibility[index]
868
898
  if (answer === undefined) {
@@ -883,7 +913,7 @@ export default {
883
913
  async refreshSourceVisibility() {
884
914
  const tabs = Array.isArray(this.content?.tabs) ? this.content.tabs : []
885
915
  const seq = ++this.visibilitySeq
886
- const ctx = { objectId: this.objectId, object: this.objectData || undefined }
916
+ const ctx = { objectId: this.objectId, object: this.conditionData || this.objectData || undefined }
887
917
  await Promise.all(tabs.map(async (tab, index) => {
888
918
  const cond = tab && typeof tab === 'object' ? tab.visibleWhen : null
889
919
  if (!cond || isLocallyDecidableVisibleWhen(cond)) {
@@ -683,11 +683,27 @@ export default {
683
683
  * deferred tour) go through here — so a centered step cannot be
684
684
  * swallowed by either.
685
685
  *
686
+ * A tour that declares `startOn` opens only on one of those route
687
+ * names, whatever its first step looks like.
688
+ *
686
689
  * @param {object} tour The tour definition.
687
690
  * @param {string} [routeName] The current route name.
688
691
  * @return {boolean} True when the tour may open on this route.
692
+ * @spec openspec/changes/a-tour-starts-on-the-pages-it-names/specs/walkthrough/spec.md
689
693
  */
690
694
  routeMatchesTour(tour, routeName) {
695
+ // A tour that names where it starts (`startOn`) opens only there.
696
+ // It wins over the centered-step rule below: a welcome card floats
697
+ // in the middle of the screen, but a user who opens a case from a
698
+ // mail link lands on that case, and the card then blocks the page
699
+ // they came for. Measured 13 October on dossiq: a staff user's first
700
+ // visit was a deep link to a Woo request, and "Welcome to Dossiq"
701
+ // opened over it. The tour waits in `_pendingAutoTour` and opens when
702
+ // the user reaches a named route.
703
+ const startOn = Array.isArray(tour && tour.startOn) ? tour.startOn.map(String) : []
704
+ if (startOn.length > 0) {
705
+ return !!routeName && startOn.includes(String(routeName))
706
+ }
691
707
  const page = this.firstStepPage(tour)
692
708
  if (!page) {
693
709
  return true
@@ -183,3 +183,17 @@
183
183
  .cn-look-board .cn-form-page__footer .cn-form-page__primary {
184
184
  margin-inline-start: auto;
185
185
  }
186
+
187
+ /* A Nextcloud text area or text field with its label inside the field hides
188
+ its placeholder until the field takes focus, so the label and the
189
+ placeholder are never drawn over each other. A theme that forces every
190
+ placeholder visible (thematiq's nldesign system sets
191
+ `::placeholder { opacity: 1 !important }`) drew both: the dossiq DqTaak
192
+ notes field read "Een notitie toevoegen" over "Wat heb je gevonden, wie heb
193
+ je gebeld?". Under the board look the placeholder stays hidden at rest, as
194
+ Nextcloud intends; a field with its label outside keeps its placeholder.
195
+ @spec openspec/changes/board-field-label-over-placeholder/specs/form-field-board-look/spec.md#requirement-a-field-label-and-its-placeholder-are-never-drawn-over-each-other */
196
+ .cn-look-board .textarea__input:not(:focus, .textarea__input--label-outside)::placeholder,
197
+ .cn-look-board .input-field:not(.input-field--label-outside) .input-field__input:not(:focus)::placeholder {
198
+ opacity: 0 !important;
199
+ }
@@ -37,6 +37,7 @@
37
37
  }
38
38
 
39
39
  .cn-form-widget__textarea {
40
+ box-sizing: border-box;
40
41
  width: 100%;
41
42
  min-height: 80px;
42
43
  padding: 8px;
@@ -58,3 +59,13 @@
58
59
  color: var(--color-error);
59
60
  margin: 0;
60
61
  }
62
+
63
+ /* A select takes its field's width, however narrow the card. NcSelect is at
64
+ least 260px wide on its own, so in pipelinq's 234px "Nieuw contactmoment"
65
+ card at 1100 wide the channel and client selects ran 40px past the card's
66
+ edge. The client picker (CnResourceSelect) is an NcSelect too.
67
+ @spec openspec/changes/interaction-form-fits-and-reads-dutch/specs/interaction-form-widget/spec.md#requirement-the-interaction-form-fits-its-card-and-reads-in-the-readers-language */
68
+ .cn-form-widget .v-select.select {
69
+ min-width: 0;
70
+ width: 100%;
71
+ }
@@ -10,6 +10,17 @@
10
10
  padding: calc(5 * var(--default-grid-baseline));
11
11
  }
12
12
 
13
+ /* A group heading in the header's Actions menu (`{ heading }` in items), as
14
+ the board's "Ga naar" and "Selectie": 12px bold capitals, muted. The menu
15
+ is a popover outside the page, so the class is not under the look class. */
16
+ .cn-index-header-menu__heading {
17
+ font-size: 12px;
18
+ font-weight: 700;
19
+ letter-spacing: 0.04em;
20
+ text-transform: uppercase;
21
+ color: var(--color-text-maxcontrast);
22
+ }
23
+
13
24
  /* The board's header buttons (`headerButtons`) at the end of the title row,
14
25
  centred on the title and its count line. They wrap under the title on a
15
26
  narrow page. */
package/src/css/index.css CHANGED
@@ -48,6 +48,7 @@
48
48
  @import './dashboard.css';
49
49
  @import './detail-page.css';
50
50
  @import './look-board-detail.css';
51
+ @import './look-board-reports.css';
51
52
  @import './reference-preview.css';
52
53
  @import './CnSchemaFormDialog.css';
53
54
  @import './timeline-stages.css';
@@ -0,0 +1,175 @@
1
+ /*
2
+ * The reports page under the board look (screens-reports-page-board-look,
3
+ * board DcRapportages): a 28px title over a muted lead, one section per
4
+ * category headed at 18px, and two-up cards with an icon tile. Scoped under
5
+ * `.cn-look-board` with the page guard, so an app without the look renders as
6
+ * before.
7
+ */
8
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) {
9
+ box-sizing: border-box;
10
+ display: flex;
11
+ flex-direction: column;
12
+ gap: var(--cn-board-section-gap);
13
+ padding: var(--cn-board-content-padding);
14
+ max-width: var(--cn-board-content-max-width);
15
+ }
16
+
17
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 14px;
21
+ padding: 4px 0 8px;
22
+ }
23
+
24
+ /* The 56px inset clears the navigation toggle only while the navigation is
25
+ closed, as on the other board pages. */
26
+ .cn-look-board:has(.app-navigation--closed) .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header {
27
+ padding-inline-start: 56px;
28
+ }
29
+
30
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__title {
31
+ margin: 0;
32
+ font-size: 28px;
33
+ font-weight: 700;
34
+ line-height: 1.2;
35
+ }
36
+
37
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__description {
38
+ margin: 0;
39
+ font-size: 14px;
40
+ color: var(--color-text-maxcontrast);
41
+ }
42
+
43
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__section {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 12px;
47
+ /* A host's own `section` rule (a width cap, a margin) does not reach it. */
48
+ max-width: none;
49
+ margin: 0;
50
+ }
51
+
52
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__section-title {
53
+ margin: 0;
54
+ font-size: 18px;
55
+ font-weight: 700;
56
+ }
57
+
58
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__grid {
59
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
60
+ gap: 14px;
61
+ }
62
+
63
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card {
64
+ box-sizing: border-box;
65
+ flex-direction: row;
66
+ align-items: flex-start;
67
+ gap: 14px;
68
+ padding: 18px 20px;
69
+ border: 1px solid var(--color-border);
70
+ border-radius: var(--cn-board-card-radius);
71
+ background: var(--color-main-background);
72
+ color: var(--color-main-text);
73
+ }
74
+
75
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card:hover,
76
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card:focus-visible {
77
+ background: var(--color-background-hover);
78
+ }
79
+
80
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-icon {
81
+ flex: none;
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ width: 44px;
86
+ height: 44px;
87
+ border-radius: 10px;
88
+ background: var(--color-primary-element-light);
89
+ color: var(--color-primary-element-light-text);
90
+ }
91
+
92
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-text {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 4px;
96
+ }
97
+
98
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-title {
99
+ font-size: 17px;
100
+ font-weight: 700;
101
+ }
102
+
103
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card-description {
104
+ font-size: 14px;
105
+ line-height: 1.5;
106
+ color: var(--color-text-maxcontrast);
107
+ }
108
+
109
+ /* The widgets under the cards sit in the page's own padding: the dashboard
110
+ inside draws no second frame. */
111
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__widgets {
112
+ margin-top: 0;
113
+ }
114
+
115
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-dashboard-page {
116
+ padding: 0;
117
+ }
118
+
119
+ /* The widget area draws no panel of its own (board DcRapportages: the
120
+ highlighted report's card sits straight on the page ground). A theme's
121
+ catch-all for anything named like a widget (nldesign's element overrides:
122
+ `[class*="widget"]:not([class*="widget__"]):not(#header *)`, an id
123
+ specificity no class rule outranks) painted `__widgets` white, hence the
124
+ `!important`. */
125
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__widgets {
126
+ background: transparent !important;
127
+ }
128
+
129
+ /* The grid insets every card by its 12px margin (CnDashboardPage's default
130
+ `gridMargin`); the page padding already places the area, so the card's
131
+ edges line up with the report cards above it. */
132
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__widgets .cn-dashboard-grid {
133
+ width: calc(100% + 24px);
134
+ margin: -12px;
135
+ }
136
+
137
+ /* A report card is a link, but its title and description read as text, as on
138
+ the board: no underline, whatever a theme does to links. */
139
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card,
140
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__card * {
141
+ text-decoration: none !important;
142
+ }
143
+
144
+ /* The header buttons beside the title (screens-reports-header-buttons, board
145
+ DcRapportages "Downloaden"): the board's 40px button with a grey border on
146
+ the main background, the primary one in the primary fill, as on the index
147
+ header. `!important` holds them under a theme that restyles every button
148
+ (nldesign). The buildiq square keeps its own rule. */
149
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header-buttons > .button-vue--secondary {
150
+ height: var(--cn-board-control-height) !important;
151
+ min-height: var(--cn-board-control-height) !important;
152
+ padding: 0 14px !important;
153
+ border: 1px solid var(--color-border-dark) !important;
154
+ border-radius: var(--cn-board-control-radius) !important;
155
+ background: var(--color-main-background) !important;
156
+ color: var(--color-main-text) !important;
157
+ }
158
+
159
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header-buttons > .button-vue--secondary:hover {
160
+ background: var(--color-background-hover) !important;
161
+ }
162
+
163
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header-buttons > .button-vue--primary {
164
+ height: var(--cn-board-control-height) !important;
165
+ min-height: var(--cn-board-control-height) !important;
166
+ padding: 0 16px !important;
167
+ border: 0 !important;
168
+ border-radius: var(--cn-board-control-radius) !important;
169
+ background: var(--color-primary-element) !important;
170
+ color: var(--color-primary-element-text) !important;
171
+ }
172
+
173
+ .cn-look-board .cn-reports-page:not(.cn-look-nextcloud *) .cn-reports-page__header-buttons > .button-vue--primary:hover {
174
+ background: var(--color-primary-element-hover) !important;
175
+ }
@@ -436,9 +436,20 @@
436
436
  align-items: start;
437
437
  }
438
438
 
439
- /* The card: a section of the shell, or a CnSettingsCard of the page. */
439
+ /* An app component that wraps its sections in one element (decidiq's
440
+ Settings.vue) takes no box of its own: its sections are grid items and
441
+ cards, as if the shell held them directly. Only an element whose own
442
+ children are sections; anything else keeps spanning the grid. */
443
+ .cn-look-board .cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section),
444
+ .cn-look-board.cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section) {
445
+ display: contents;
446
+ }
447
+
448
+ /* The card: a section of the shell (also one inside a wrapping element), or a CnSettingsCard of the page. */
440
449
  .cn-look-board .cn-admin-settings-shell:not(.cn-look-nextcloud *) > .settings-section,
441
450
  .cn-look-board.cn-admin-settings-shell:not(.cn-look-nextcloud *) > .settings-section,
451
+ .cn-look-board .cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section) > .settings-section,
452
+ .cn-look-board.cn-admin-settings-shell:not(.cn-look-nextcloud *) > :not(.settings-section):has(> .settings-section) > .settings-section,
442
453
  .cn-look-board .cn-settings-page__sections:not(.cn-look-nextcloud *) > .cn-settings-card {
443
454
  box-sizing: border-box;
444
455
  display: flex;
@@ -465,7 +476,12 @@
465
476
  display: none;
466
477
  }
467
478
 
479
+ /* NcSettingsSection draws its name `display: inline-flex; justify-content:
480
+ center`; in the card's flex column the name spans the card, so the title
481
+ sat centred. The board's card titles start at the left edge. */
468
482
  .cn-look-board .settings-section:not(.cn-look-nextcloud *) .settings-section__name {
483
+ justify-content: flex-start;
484
+ text-align: start;
469
485
  margin: 0;
470
486
  font-size: 19px;
471
487
  font-weight: 700;
@@ -600,6 +616,18 @@
600
616
  font-weight: 600;
601
617
  }
602
618
 
619
+ /* ---- Dashboard card titles --------------------------------------------- */
620
+ /* A card's title on a dashboard (and on the widgets under a reports page) is
621
+ the board's card h2: 17px weight 700 (boards PqMijnWerk, DcRapportages and
622
+ the app dashboards; `--cn-board-heading-size`, as on a detail page's cards).
623
+ The wrapper's own 14px weight 600 stays for the Nextcloud look, and the
624
+ native-dashboard chrome keeps its 20px. */
625
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-widget-wrapper:not(.cn-widget-wrapper--nc-dashboard) .cn-widget-wrapper__title {
626
+ font-size: var(--cn-board-heading-size, 17px);
627
+ font-weight: 700;
628
+ line-height: 1.3;
629
+ }
630
+
603
631
  /* ---- Record list on a dashboard card (PtDashboard) ---------------------- */
604
632
  /* The dashboard's card body is flush, so the rows carry their own inset: the
605
633
  board's card pads 24px at the sides and each row 14px, a soft hairline
@@ -667,6 +695,21 @@
667
695
  margin-inline-start: auto;
668
696
  }
669
697
 
698
+ /* The title and meta take the room the pill and the date leave, and a long
699
+ title wraps inside it: the pill and the date stay on the title line, as the
700
+ board draws them. They drop under the text only when the card is too narrow
701
+ to hold 240px of text beside them (PqMijnWerk). */
702
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-record-list__main {
703
+ flex: 1 1 240px;
704
+ }
705
+
706
+ /* ---- Saved views on a dashboard card (PqMijnWerk) ------------------------ */
707
+ /* The card body is flush, so the list and its empty line carry the card's
708
+ inset themselves, the same 24px at the sides as a record list. */
709
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-saved-views-widget {
710
+ padding: 4px 24px 20px;
711
+ }
712
+
670
713
  /* The board draws no chevron after the pill and date. */
671
714
  .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-record-list__chevron {
672
715
  display: none;
@@ -14,6 +14,7 @@
14
14
  @closing="$emit('close')">
15
15
  <div
16
16
  class="cn-transition-input"
17
+ :aria-busy="busy ? 'true' : null"
17
18
  data-testid="cn-modal"
18
19
  data-testid-modal="cn-transition-input-dialog">
19
20
  <!-- A refusal keeps this dialog open with what was typed still in
@@ -29,8 +30,18 @@
29
30
  class="cn-transition-input__field"
30
31
  :class="{ 'cn-transition-input__field--refused': isRefused(field.key) }"
31
32
  :data-testid="`cn-transition-input-${field.key}`">
33
+ <!-- An input that declares its choices is picked from them. -->
34
+ <NcSelect v-if="field.widget === 'select'"
35
+ :modelValue="selectedOption(field)"
36
+ :options="field.options"
37
+ label="label"
38
+ :inputLabel="requiredLabel(field)"
39
+ :clearable="!field.required"
40
+ :data-testid="`cn-transition-input-select-${field.key}`"
41
+ @update:modelValue="setValue(field.key, $event && $event.value !== undefined ? $event.value : '')" />
42
+
32
43
  <!-- An input that points at another record is picked, never typed. -->
33
- <div v-if="field.widget === 'reference'" class="cn-transition-input__reference">
44
+ <div v-else-if="field.widget === 'reference'" class="cn-transition-input__reference">
34
45
  <label class="cn-transition-input__label">{{ requiredLabel(field) }}</label>
35
46
  <CnResourceSelect
36
47
  :register="field.referenceRegister"
@@ -128,11 +139,18 @@
128
139
  @click="$emit('close')">
129
140
  {{ t('nextcloud-vue', 'Cancel') }}
130
141
  </NcButton>
142
+ <!-- Busy while the parent's POST runs: a transition can take
143
+ seconds, and a confirm button that only greys out reads as one
144
+ that did nothing. -->
131
145
  <NcButton
132
146
  variant="primary"
133
147
  :disabled="!canConfirm"
148
+ :aria-busy="busy ? 'true' : null"
134
149
  data-testid="cn-transition-input-confirm"
135
150
  @click="onConfirm">
151
+ <template v-if="busy" #icon>
152
+ <NcLoadingIcon :size="20" data-testid="cn-transition-input-busy" />
153
+ </template>
136
154
  {{ confirmLabel }}
137
155
  </NcButton>
138
156
  </template>
@@ -141,7 +159,7 @@
141
159
 
142
160
  <script>
143
161
  import { translate as t } from '@nextcloud/l10n'
144
- import { NcButton, NcCheckboxRadioSwitch, NcTextArea, NcTextField } from '@nextcloud/vue'
162
+ import { NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue'
145
163
  import CnDialog from '../components/CnDialog/CnDialog.vue'
146
164
  import CnResourceSelect from '../components/CnResourceSelect/CnResourceSelect.vue'
147
165
  import { dialogBoardMixin } from '../mixins/dialogBoard.js'
@@ -164,7 +182,13 @@ import { fieldsFromSchema } from '../utils/schema.js'
164
182
  * from the property `title`, booleans render as a switch, numbers as a number
165
183
  * field, and long text (`maxLength > 255` or a `textarea`/`markdown` format)
166
184
  * as a textarea. Anything else — including a field the schema does not
167
- * declare — falls back to a plain labelled text input. Deliberately minimal:
185
+ * declare — falls back to a plain labelled text input.
186
+ *
187
+ * An input may also say how it is asked for, which matters when the choices
188
+ * depend on the record rather than on its schema: `label` names the field,
189
+ * and `options: [{ value, label }]` turns it into a picker that shows each
190
+ * `label` and sends the chosen `value`. An input that declares neither is
191
+ * rendered from the schema as before. Deliberately minimal:
168
192
  * text, textarea, number and checkbox cover transition inputs; this is not a
169
193
  * form engine (use `CnFormDialog` for full object forms).
170
194
  *
@@ -186,9 +210,11 @@ export default {
186
210
  CnResourceSelect,
187
211
  CnDialog,
188
212
  NcButton,
213
+ NcLoadingIcon,
189
214
  NcTextField,
190
215
  NcTextArea,
191
216
  NcCheckboxRadioSwitch,
217
+ NcSelect,
192
218
  },
193
219
 
194
220
  mixins: [dialogBoardMixin],
@@ -199,7 +225,11 @@ export default {
199
225
  * `x-openregister-lifecycle.transitions.<action>.inputs` (server-derived
200
226
  * or config-declared); `label` doubles as dialog title + confirm label.
201
227
  *
202
- * @type {{action?: string, label?: string, inputs?: Array<{field: string, required?: boolean}>}}
228
+ * An input may carry a `label` for its field and `options` to pick
229
+ * from, each `{ value, label }`; the picker shows the label and sends
230
+ * the value.
231
+ *
232
+ * @type {{action?: string, label?: string, inputs?: Array<{field: string, required?: boolean, label?: string, options?: Array<{value: string, label?: string}>}>}}
203
233
  */
204
234
  transition: {
205
235
  type: Object,
@@ -296,6 +326,8 @@ export default {
296
326
  required: input.required === true,
297
327
  picker: input.picker && typeof input.picker === 'object' ? input.picker : null,
298
328
  fields: Array.isArray(input.fields) ? input.fields.filter((f) => typeof f === 'string') : null,
329
+ label: typeof input.label === 'string' && input.label.trim() !== '' ? input.label.trim() : null,
330
+ options: this.declaredOptions(input.options),
299
331
  }))
300
332
  },
301
333
 
@@ -305,26 +337,40 @@ export default {
305
337
  * widget from the type/format); the widget is then clamped to the minimal
306
338
  * set this dialog renders (text / textarea / number / checkbox). Fields
307
339
  * the schema does not declare fall back to a plain text input.
340
+ *
341
+ * An input that declares `options` is a picker whatever the schema
342
+ * says, because its choices are the ones this transition offers on
343
+ * this record. A declared `label` names the field over the schema's.
308
344
  */
309
345
  fields() {
310
346
  const keys = this.inputs.map((input) => input.field)
311
347
  const resolved = fieldsFromSchema(this.schema, { include: keys, includeReadOnly: true })
312
348
  return this.inputs.map((input) => {
313
349
  const field = resolved.find((f) => f.key === input.field)
350
+ if (input.options) {
351
+ return {
352
+ key: input.field,
353
+ label: input.label || (field && field.label) || input.field,
354
+ description: (field && field.description) || '',
355
+ widget: 'select',
356
+ options: input.options,
357
+ required: input.required,
358
+ }
359
+ }
314
360
  const prop = (this.schema && this.schema.properties && this.schema.properties[input.field]) || null
315
361
  if (prop && prop.type === 'object' && prop.properties && typeof prop.properties === 'object') {
316
362
  return this.objectField(input, prop)
317
363
  }
318
364
  if (!field) {
319
- return { key: input.field, label: input.field, description: '', widget: 'text', required: input.required }
365
+ return { key: input.field, label: input.label || input.field, description: '', widget: 'text', required: input.required }
320
366
  }
321
367
  if (field.reference && field.reference.multiple !== true) {
322
368
  const register = field.reference.register || this.register
323
369
  if (register !== '') {
324
- return { ...field, widget: 'reference', referenceRegister: register, picker: input.picker, required: input.required }
370
+ return { ...field, label: input.label || field.label, widget: 'reference', referenceRegister: register, picker: input.picker, required: input.required }
325
371
  }
326
372
  }
327
- return { ...field, widget: this.clampWidget(field.widget), required: input.required }
373
+ return { ...field, label: input.label || field.label, widget: this.clampWidget(field.widget), required: input.required }
328
374
  })
329
375
  },
330
376
 
@@ -438,6 +484,40 @@ export default {
438
484
  }
439
485
  },
440
486
 
487
+ /**
488
+ * The choices an input declares, as `[{ value, label }]`, or null when
489
+ * it declares none. An option without a value is dropped, because it
490
+ * cannot be sent; one without a label shows its value.
491
+ *
492
+ * @param {unknown} options The input's declared `options`.
493
+ * @return {Array<{value: string, label: string}>|null}
494
+ */
495
+ declaredOptions(options) {
496
+ if (!Array.isArray(options)) {
497
+ return null
498
+ }
499
+ const usable = options
500
+ .filter((option) => option && typeof option === 'object'
501
+ && (typeof option.value === 'string' || typeof option.value === 'number')
502
+ && String(option.value).trim() !== '')
503
+ .map((option) => ({
504
+ value: String(option.value),
505
+ label: typeof option.label === 'string' && option.label.trim() !== '' ? option.label.trim() : String(option.value),
506
+ }))
507
+ return usable.length > 0 ? usable : null
508
+ },
509
+
510
+ /**
511
+ * The option a picker field currently holds, or null.
512
+ *
513
+ * @param {object} field The select field descriptor.
514
+ * @return {{value: string, label: string}|null}
515
+ */
516
+ selectedOption(field) {
517
+ const value = this.values[field.key]
518
+ return field.options.find((option) => option.value === String(value ?? '')) || null
519
+ },
520
+
441
521
  /**
442
522
  * Ids a reference picker must not offer: the current record, when the
443
523
  * input's `picker.excludeSelf` says so.