@conduction/nextcloud-vue 2.138.0 → 2.140.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 (115) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +9 -2
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  4. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +17 -1
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +2 -2
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  10. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +52 -1
  11. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +23 -1
  13. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  14. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +106 -0
  16. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +30 -7
  18. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  20. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +59 -3
  21. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  22. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +44 -34
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  24. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  25. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +28 -1
  26. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFilesBrowser/filesBrowser.js +7 -1
  29. package/dist/esm/components/CnFilesBrowser/filesBrowser.js.map +1 -1
  30. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +5 -0
  31. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +129 -6
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js +2 -0
  37. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
  38. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js +13 -2
  39. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
  40. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  41. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +28 -3
  42. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  43. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  45. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +1 -1
  46. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  47. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js +1 -1
  49. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js +10 -0
  50. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js.map +1 -1
  51. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js.map +1 -1
  52. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue.js +1 -1
  53. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +12 -1
  54. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +1 -1
  56. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js +1 -1
  58. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js +23 -1
  59. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js.map +1 -1
  60. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js +43 -13
  61. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js.map +1 -1
  62. package/dist/esm/composables/indexSources.js +48 -6
  63. package/dist/esm/composables/indexSources.js.map +1 -1
  64. package/dist/esm/composables/useTaskInboxStore.js +4 -3
  65. package/dist/esm/composables/useTaskInboxStore.js.map +1 -1
  66. package/dist/esm/l10n/en.json.js +4 -1
  67. package/dist/esm/l10n/en.json.js.map +1 -1
  68. package/dist/esm/l10n/nl.json.js +7 -1
  69. package/dist/esm/l10n/nl.json.js.map +1 -1
  70. package/dist/esm/library-style.js +1 -1
  71. package/dist/esm/nextcloud-vue.css +604 -407
  72. package/dist/esm/utils/builtInFormatters.js +23 -1
  73. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  74. package/dist/esm/utils/fillTemplate.js +7 -1
  75. package/dist/esm/utils/fillTemplate.js.map +1 -1
  76. package/dist/esm/utils/myTeam.js +24 -4
  77. package/dist/esm/utils/myTeam.js.map +1 -1
  78. package/dist/esm/utils/resolveFilterTokens.js +5 -4
  79. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  80. package/dist/esm/utils/transitionInputs.js +43 -2
  81. package/dist/esm/utils/transitionInputs.js.map +1 -1
  82. package/dist/nextcloud-vue.cjs.js +932 -236
  83. package/dist/nextcloud-vue.cjs.js.map +2 -2
  84. package/dist/nextcloud-vue.css +604 -407
  85. package/l10n/en.json +4 -1
  86. package/l10n/nl.json +7 -1
  87. package/package.json +1 -1
  88. package/src/components/CnAppRoot/CnAppRoot.vue +9 -2
  89. package/src/components/CnCellRenderer/CnCellRenderer.vue +19 -3
  90. package/src/components/CnChartWidget/CnChartWidget.md +1 -0
  91. package/src/components/CnChartWidget/CnChartWidget.vue +76 -1
  92. package/src/components/CnConversationThread/CnConversationWidget.vue +126 -1
  93. package/src/components/CnFactsWidget/CnFactsWidget.vue +78 -9
  94. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +39 -1
  95. package/src/components/CnFilesBrowser/filesBrowser.js +7 -1
  96. package/src/components/CnFilesBrowser/filesBrowserColumns.js +5 -0
  97. package/src/components/CnIndexPage/CnIndexPage.vue +131 -8
  98. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +10 -2
  99. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +28 -3
  100. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +1 -1
  101. package/src/components/CnRecordListWidget/CnRecordNoteRows.vue +10 -0
  102. package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +68 -2
  103. package/src/components/CnTransitionInputFields/CnTransitionInputFields.vue +43 -1
  104. package/src/composables/indexSources.js +48 -6
  105. package/src/composables/useTaskInboxStore.js +4 -3
  106. package/src/css/dialog.css +18 -0
  107. package/src/css/look-board-detail.css +24 -0
  108. package/src/css/look-board-index.css +21 -0
  109. package/src/css/look-board.css +31 -2
  110. package/src/css/table.css +13 -0
  111. package/src/utils/builtInFormatters.js +22 -0
  112. package/src/utils/fillTemplate.js +7 -1
  113. package/src/utils/myTeam.js +24 -4
  114. package/src/utils/resolveFilterTokens.js +5 -4
  115. package/src/utils/transitionInputs.js +42 -1
@@ -162,6 +162,26 @@
162
162
  </p>
163
163
  </div>
164
164
 
165
+ <!-- A days input set as a date (`dateField`): today plus the days,
166
+ and a chosen date sets the days. -->
167
+ <div v-if="field.input.dateField"
168
+ :key="`${field.key}-date`"
169
+ class="cn-transition-input-fields__field cn-transition-input-fields__wide"
170
+ :data-testid="field.input.dateField.testid || `cn-transition-input-date-${field.key}`">
171
+ <label class="cn-transition-input-fields__label"
172
+ :for="`${inputId(field)}-date`">{{ dateFieldLabel(field) }}</label>
173
+ <NcTextField :id="`${inputId(field)}-date`"
174
+ :modelValue="field.dateValue"
175
+ type="date"
176
+ :label="dateFieldLabel(field)"
177
+ :labelOutside="true"
178
+ :disabled="disabled"
179
+ @update:modelValue="setValue(field.key, daysUntilIso($event))" />
180
+ <span v-if="field.input.dateField.note" class="cn-transition-input-fields__muted">
181
+ {{ field.input.dateField.note }}
182
+ </span>
183
+ </div>
184
+
165
185
  <!-- What the input means once filled in, such as the day a paused
166
186
  term comes back. Shown only once it can be worked out. -->
167
187
  <div v-if="field.previewText"
@@ -209,7 +229,7 @@ import { NcCheckboxRadioSwitch, NcSelect, NcTextArea, NcTextField } from '@nextc
209
229
  import CnResourceSelect from '../CnResourceSelect/CnResourceSelect.vue'
210
230
  import CnTemplateSelect from '../CnTemplateSelect/CnTemplateSelect.vue'
211
231
  import { readPath } from '../../utils/readPath.js'
212
- import { applyTemplateText, isInputFilled, resolveInputFields } from '../../utils/transitionInputs.js'
232
+ import { applyTemplateText, daysUntilIso, isInputFilled, resolveInputFields } from '../../utils/transitionInputs.js'
213
233
  import { CnIcon } from '../CnIcon/index.js'
214
234
 
215
235
  let uid = 0
@@ -505,6 +525,28 @@ export default {
505
525
  return this.markOptional && !field.required ? `${field.label} (${t('nextcloud-vue', 'optional')})` : field.label
506
526
  },
507
527
 
528
+ /**
529
+ * The label of a days input's date field (`dateField`), with the
530
+ * optional marker when asked for: the date is never required.
531
+ *
532
+ * @param {object} field The descriptor.
533
+ * @return {string}
534
+ */
535
+ dateFieldLabel(field) {
536
+ const label = String(field.input.dateField.label || '')
537
+ return this.markOptional ? `${label} (${t('nextcloud-vue', 'optional')})` : label
538
+ },
539
+
540
+ /**
541
+ * The days a chosen date stands for.
542
+ *
543
+ * @param {string} iso The date input's value.
544
+ * @return {string} The days, '' without a date.
545
+ */
546
+ daysUntilIso(iso) {
547
+ return daysUntilIso(iso)
548
+ },
549
+
508
550
  /**
509
551
  * Label of a sub-field, with the required marker.
510
552
  *
@@ -6,9 +6,11 @@
6
6
  import { getCanonicalLocale, translate as t } from '@nextcloud/l10n'
7
7
  import { generateOcsUrl, generateUrl } from '@nextcloud/router'
8
8
  import { reactive } from 'vue'
9
+ import AccountOutline from 'vue-material-design-icons/AccountOutline.vue'
9
10
  import CalendarClock from 'vue-material-design-icons/CalendarClock.vue'
10
11
  import Cancel from 'vue-material-design-icons/Cancel.vue'
11
12
  import Check from 'vue-material-design-icons/Check.vue'
13
+ import EmailOutline from 'vue-material-design-icons/EmailOutline.vue'
12
14
  import HandBackRight from 'vue-material-design-icons/HandBackRight.vue'
13
15
  import LightningBolt from 'vue-material-design-icons/LightningBolt.vue'
14
16
  import Pencil from 'vue-material-design-icons/Pencil.vue'
@@ -19,6 +21,7 @@ import { fillTemplate } from '../utils/fillTemplate.js'
19
21
  import { FLOW_RUN_ERROR_STATUSES, flowRunStatuses } from '../utils/flowRunStatus.js'
20
22
  import { fetchGroupMembers, fetchPresence, fetchStatuses, fetchUsers } from '../utils/nextcloudDirectory.js'
21
23
  import { readPath } from '../utils/readPath.js'
24
+ import { resolveNextcloudUser } from '../utils/userAutocomplete.js'
22
25
  import { useFlowStore } from './useFlowStore.js'
23
26
  import { organisationRecord, useOrganisationStore } from './useOrganisationStore.js'
24
27
  import { useTaskInboxStore } from './useTaskInboxStore.js'
@@ -699,12 +702,15 @@ function tasksSource() {
699
702
  *
700
703
  * @param {string} key The column key.
701
704
  * @param {string} order `asc` or `desc`.
702
- * @return {{sort: string}|null} The parameter, or null for a column the inbox cannot sort by.
705
+ * Open tasks come before finished ones (`openFirst`), each in that order.
706
+ *
707
+ * @return {{sort: string, openFirst: boolean}|null} The parameters, or null for a column the inbox cannot sort by.
703
708
  * @spec openspec/changes/tasks-source-sorts-on-server/specs/index-sources/spec.md#requirement-a-named-source-can-sort-on-the-server
709
+ * @spec openspec/changes/tasks-source-open-first/specs/index-sources/spec.md#requirement-a-sorted-task-list-puts-open-tasks-first
704
710
  */
705
711
  sortParam: (key, order) => {
706
712
  const field = TASK_SORT_FIELDS[key]
707
- return field ? { sort: (order === 'desc' ? '-' : '') + field } : null
713
+ return field ? { sort: (order === 'desc' ? '-' : '') + field, openFirst: true } : null
708
714
  },
709
715
 
710
716
  // The scope tabs. Each tab's filter is merged over the page's
@@ -1088,7 +1094,7 @@ function fillRowTemplate(template, row) {
1088
1094
  * and its count (DqZoekresultaten).
1089
1095
  *
1090
1096
  * `sourceConfig.kinds` lists what is searched:
1091
- * `{ id, label, register, schema, titleField?, subtitleField?, subtitleTemplate?, statusField?, statusResolve?, linkedField?, linkedTemplate?, href?, limit? }`
1097
+ * `{ id, label, register, schema, titleField?, subtitleField?, subtitleTemplate?, statusField?, statusResolve?, linkedField?, linkedTemplate?, linkedUser?, href?, limit? }`
1092
1098
  * for objects (OpenRegister's `_search`, so the reader sees what they may
1093
1099
  * read), or `{ id, label, files: true, limit? }` for the reader's files
1094
1100
  * (Nextcloud's unified search). The term is `sourceConfig.term`, else the
@@ -1104,6 +1110,8 @@ function fillRowTemplate(template, row) {
1104
1110
  * column, drawn when a kind declares one; `sourceConfig.linkedLabel` names it.
1105
1111
  * `subtitleTemplate` ("Case {identifier}", translated) fills the line under a
1106
1112
  * result, else `subtitleField`.
1113
+ * `linkedUser: true` reads that value as a user id and shows the user's
1114
+ * display name. `sourceConfig.columnWidths` sets a width per column key.
1107
1115
  * The page's title and count line can name the term as `{term}`, and the
1108
1116
  * count is the active kind's total. Kind, link and status are filterable.
1109
1117
  *
@@ -1113,6 +1121,23 @@ function fillRowTemplate(template, row) {
1113
1121
  * @return {object} The source adapter.
1114
1122
  * @spec openspec/changes/index-search-source/specs/index-page/spec.md#requirement-an-index-page-can-search-several-schemas-and-files
1115
1123
  */
1124
+ /**
1125
+ * Columns with the widths a page names per column key
1126
+ * (`sourceConfig.columnWidths`, `{ title: '250px' }`); a column it does not
1127
+ * name keeps none.
1128
+ *
1129
+ * @param {Array<object>} columns The columns.
1130
+ * @param {object|undefined} widths The widths by key.
1131
+ * @return {Array<object>} The columns.
1132
+ * @spec openspec/changes/search-linked-user-and-widths/specs/search-linked-user-and-widths/spec.md
1133
+ */
1134
+ function withColumnWidths(columns, widths) {
1135
+ if (!widths || typeof widths !== 'object') {
1136
+ return columns
1137
+ }
1138
+ return columns.map((col) => (typeof widths[col.key] === 'string' && widths[col.key] !== '' ? { ...col, width: widths[col.key] } : col))
1139
+ }
1140
+
1116
1141
  function searchSource(config = {}, context = {}) {
1117
1142
  const translate = typeof context.translate === 'function' ? context.translate : (text) => text
1118
1143
  const kinds = (Array.isArray(config && config.kinds) ? config.kinds : [])
@@ -1208,12 +1233,20 @@ function searchSource(config = {}, context = {}) {
1208
1233
  return kind.subtitleField ? String(readPath(object, kind.subtitleField) ?? '') : ''
1209
1234
  }
1210
1235
 
1236
+ // `linkedUser`: the Linked cell holds a user id; name the user instead.
1237
+ const linkedUserNames = async (kind, objects) => {
1238
+ const ids = [...new Set(objects.map((object) => linkedOf(kind, object)).filter((id) => id !== ''))]
1239
+ const options = await Promise.all(ids.map((id) => resolveNextcloudUser(id).catch(() => null)))
1240
+ return new Map(ids.map((id, index) => [id, (options[index] && options[index].label) || id]))
1241
+ }
1242
+
1211
1243
  const searchObjects = async (axios, kind, term) => {
1212
1244
  const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: kind.register, schema: kind.schema })
1213
1245
  const res = await axios.get(url, { params: { _search: term, _limit: kind.limit || 20 } })
1214
1246
  const body = (res && res.data) || {}
1215
1247
  const results = Array.isArray(body.results) ? body.results : (Array.isArray(body) ? body : [])
1216
1248
  const labels = kind.statusResolve && kind.statusField ? await statusLabels(kind) : null
1249
+ const userNames = kind.linkedUser === true ? await linkedUserNames(kind, results) : null
1217
1250
  return {
1218
1251
  total: typeof body.total === 'number' ? body.total : results.length,
1219
1252
  rows: results.map((object) => {
@@ -1230,7 +1263,7 @@ function searchSource(config = {}, context = {}) {
1230
1263
  title: String(readPath(object, kind.titleField || 'title') ?? object.name ?? id ?? ''),
1231
1264
  subtitle: subtitleOf(kind, object),
1232
1265
  status,
1233
- linked: linkedOf(kind, object),
1266
+ linked: userNames ? (userNames.get(linkedOf(kind, object)) || linkedOf(kind, object)) : linkedOf(kind, object),
1234
1267
  href: kind.href ? generateUrl(fillRowTemplate(kind.href, { ...object, id })) : '',
1235
1268
  object,
1236
1269
  }
@@ -1338,7 +1371,7 @@ function searchSource(config = {}, context = {}) {
1338
1371
  */
1339
1372
  loading: () => state.loading,
1340
1373
 
1341
- columns: [
1374
+ columns: withColumnWidths([
1342
1375
  { key: 'title', label: t('nextcloud-vue', 'Result'), secondary: 'subtitle', sortable: true },
1343
1376
  // `kindAsText`: what one row is, as plain text ("Zaak", "Lantaarnpaal",
1344
1377
  // DqZoekresultaten), instead of the plural kind as a pill.
@@ -1353,7 +1386,7 @@ function searchSource(config = {}, context = {}) {
1353
1386
  widgetProps: { colorMap: (config && config.statusColorMap) || {} },
1354
1387
  sortable: true,
1355
1388
  },
1356
- ],
1389
+ ], config && config.columnWidths),
1357
1390
 
1358
1391
  /**
1359
1392
  * Every column header carries a filter button (DqZoekresultaten); the
@@ -1603,6 +1636,15 @@ function usersSource() {
1603
1636
  /** A filter button on every head (DqAfdelingen); the members read are narrowed here. */
1604
1637
  headerFilters: true,
1605
1638
 
1639
+ /**
1640
+ * A member's row menu (DqAfdelingen): their Nextcloud profile, and an
1641
+ * e-mail when they have an address. Both are links.
1642
+ */
1643
+ rowActions: [
1644
+ { id: 'open-profile', label: t('nextcloud-vue', 'Open profile'), icon: AccountOutline, href: (row) => generateUrl(`/u/${encodeURIComponent(row.id)}`) },
1645
+ { id: 'send-email', label: t('nextcloud-vue', 'Send email'), icon: EmailOutline, href: (row) => `mailto:${row.email}`, visible: (row) => Boolean(row && row.email) },
1646
+ ],
1647
+
1606
1648
  showAdd: false,
1607
1649
  }
1608
1650
  }
@@ -41,7 +41,8 @@ export const FLOW_TASKS_URL = '/apps/openregister/api/flow-tasks'
41
41
  // creating app named it, so it is the one filter that lets a Tasks page say
42
42
  // "the reminders" without the engine learning what a reminder is. It widens
43
43
  // nothing: it narrows within the inbox the session already decides.
44
- const ALLOWED_PARAMS = ['scope', 'state', 'isTerminal', 'priority', 'kind', 'overdue', 'dueAfter', 'dueBefore', 'objectUuid', 'sort', 'limit', 'offset']
44
+ // `openFirst` asks open tasks before finished ones, a sort the engine applies.
45
+ const ALLOWED_PARAMS = ['scope', 'state', 'isTerminal', 'priority', 'kind', 'overdue', 'dueAfter', 'dueBefore', 'objectUuid', 'sort', 'openFirst', 'limit', 'offset']
45
46
 
46
47
  /**
47
48
  * The flat body `POST /api/flow-tasks` takes for a task on a record.
@@ -178,7 +179,7 @@ export const useTaskInboxStore = defineStore('cnTaskInbox', {
178
179
  if (value === undefined || value === null || value === '') {
179
180
  continue
180
181
  }
181
- params[key] = (key === 'overdue' || key === 'isTerminal') ? String(value) : value
182
+ params[key] = (key === 'overdue' || key === 'isTerminal' || key === 'openFirst') ? String(value) : value
182
183
  }
183
184
  const response = await axios.get(generateUrl(FLOW_TASKS_URL), { params })
184
185
  const results = response.data?.results || []
@@ -358,7 +359,7 @@ export const useTaskInboxStore = defineStore('cnTaskInbox', {
358
359
  }
359
360
  // The endpoint reads `overdue` as a boolean-ish string; a quick
360
361
  // filter authors it as `true` for legibility.
361
- params[key] = (key === 'overdue' || key === 'isTerminal') ? String(value) : value
362
+ params[key] = (key === 'overdue' || key === 'isTerminal' || key === 'openFirst') ? String(value) : value
362
363
  }
363
364
 
364
365
  this.loading = true
@@ -204,6 +204,24 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .modal-mask {
204
204
  gap: 10px;
205
205
  }
206
206
 
207
+ /* A choice card with a checkbox (CnAssignDialog's toggles) is as tight as
208
+ the board draws it (DqOverdragen, 76px for a label and a two-line note):
209
+ 10px 14px padding, the checkbox row at the 24px minimum target instead of
210
+ NcCheckboxRadioSwitch's 34px, the note at 1.2 line height. */
211
+ .cn-dialog-board .cn-assign-dialog__toggle {
212
+ padding: 10px 14px;
213
+ }
214
+
215
+ .cn-dialog-board .cn-assign-dialog__toggle .checkbox-radio-switch-checkbox .checkbox-content,
216
+ .cn-dialog-board .cn-assign-dialog__toggle .checkbox-radio-switch__input {
217
+ min-height: 24px;
218
+ padding-block: 0;
219
+ }
220
+
221
+ .cn-dialog-board .cn-assign-dialog__toggle-note {
222
+ line-height: 1.2;
223
+ }
224
+
207
225
  /* Body: padding 20px 24px, 18px between blocks. */
208
226
  .cn-dialog-board .dialog__content {
209
227
  display: flex;
@@ -579,9 +579,16 @@
579
579
  .cn-look-board .cn-conversation-thread .cn-conversation-thread__meta:not(.cn-look-nextcloud *),
580
580
  .cn-look-board .cn-conversation-thread .cn-conversation-thread__meta time:not(.cn-look-nextcloud *) {
581
581
  color: var(--cn-board-text-soft);
582
+ font-size: 13px;
582
583
  line-height: normal;
583
584
  }
584
585
 
586
+ /* 6px between the meta line and the text.
587
+ @spec openspec/changes/board-conversation-bubble-meta-gap/specs/board-conversation-bubble-meta-gap/spec.md#requirement-a-board-message-sets-its-meta-line-at-13px-6px-above-the-text */
588
+ .cn-look-board .cn-conversation-thread .cn-conversation-thread__bubble:not(.cn-look-nextcloud *) {
589
+ gap: 6px;
590
+ }
591
+
585
592
  .cn-look-board .cn-conversation-thread .cn-conversation-thread__message--us .cn-conversation-thread__bubble:not(.cn-look-nextcloud *) {
586
593
  color: var(--color-main-text);
587
594
  }
@@ -1270,6 +1277,11 @@
1270
1277
  line-height: normal;
1271
1278
  }
1272
1279
 
1280
+ /* Head and body text at line height 1.2, as the board draws the table. */
1281
+ .cn-look-board .cn-detail-page .cn-widget-wrapper__content > .cn-tasks-widget .cn-tasks-widget__table :is(th, td) {
1282
+ line-height: 1.2;
1283
+ }
1284
+
1273
1285
  /* A long task name wraps under itself, as the board draws it, rather than
1274
1286
  widening the table past the card. */
1275
1287
  .cn-look-board .cn-detail-page .cn-widget-wrapper__content > .cn-tasks-widget .cn-tasks-widget__table :is(.cn-tasks-widget__name, .cn-tasks-widget__meta) {
@@ -1569,3 +1581,15 @@
1569
1581
  font-size: 14px;
1570
1582
  line-height: 1.2;
1571
1583
  }
1584
+
1585
+ /* The contacts tab inside a detail card (DqZaakBetrokkenen "Partijen") takes
1586
+ the card's padding instead of its own, and its buttons start at the card's
1587
+ edge under the subtitle.
1588
+ @spec openspec/changes/board-contacts-in-card/specs/contacts/spec.md */
1589
+ .cn-look-board .cn-detail-page__leaf-card .cn-contacts-tab {
1590
+ padding: 0;
1591
+ }
1592
+
1593
+ .cn-look-board .cn-detail-page__leaf-card .cn-contacts-tab .cn-contacts-tab__header {
1594
+ justify-content: flex-start;
1595
+ }
@@ -71,6 +71,27 @@
71
71
  margin-inline-start: 0;
72
72
  }
73
73
 
74
+ /* With the side panel open the buttons take the row under the title, the
75
+ breadcrumb and the count line follow (DqZakenSorteren).
76
+ @spec openspec/changes/board-header-buttons-under-title-beside-side-panel/specs/board-header-buttons-under-title-beside-side-panel/spec.md#requirement-the-header-buttons-sit-under-the-title-while-the-side-panel-is-open */
77
+ .cn-look-board.cn-index-page--sidebar-open .cn-page-header .cn-index-page__header-buttons,
78
+ .cn-look-board .cn-index-page--sidebar-open .cn-page-header .cn-index-page__header-buttons {
79
+ grid-column: 1 / -1;
80
+ grid-row: 2;
81
+ justify-self: start;
82
+ margin-block: 6px 3px;
83
+ }
84
+
85
+ .cn-look-board.cn-index-page--sidebar-open .cn-page-header .cn-header-trail,
86
+ .cn-look-board .cn-index-page--sidebar-open .cn-page-header .cn-header-trail {
87
+ grid-row: 3;
88
+ }
89
+
90
+ .cn-look-board.cn-index-page--sidebar-open .cn-page-header .cn-page-header__text > .cn-page-header__description,
91
+ .cn-look-board .cn-index-page--sidebar-open .cn-page-header .cn-page-header__text > .cn-page-header__description {
92
+ grid-row: 4;
93
+ }
94
+
74
95
  /* The header buttons keep the board's button under a theme that styles every
75
96
  secondary or primary button with `!important` (the nldesign theme: a
76
97
  primary border, 8px 16px padding): Download and the Actions menu are 40px,
@@ -321,6 +321,20 @@
321
321
  border: 0;
322
322
  }
323
323
 
324
+ /* A dashboard banner's kicker is a 13px bold line in sentence case, no
325
+ tracking (DqMijnWerk "Vandaag eerst", BqMijnGoedkeuringen "First, today");
326
+ the plain look keeps its spaced capitals. The kicker is a paragraph, so its
327
+ size and weight also go to the theme's paragraph tokens.
328
+ @spec openspec/changes/board-banner-kicker-case/specs/board-banner-kicker-case/spec.md#requirement-a-board-dashboard-banners-kicker-is-in-sentence-case */
329
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) .cn-banner-widget__kicker {
330
+ --nldesign-component-paragraph-font-size: 13px;
331
+ --nldesign-component-paragraph-font-weight: 700;
332
+ font-size: 13px;
333
+ font-weight: 700;
334
+ letter-spacing: 0;
335
+ text-transform: none;
336
+ }
337
+
324
338
  /* ---- A workspace filter form (CnWorkspaceFilterWidget) ----------------- */
325
339
  /* The boards draw the form's field and its load button 40px tall, the button
326
340
  16px in at both sides (DqTermijnen "Jaar" with "Rapport laden"); the form
@@ -647,6 +661,13 @@
647
661
  --button-size: 34px;
648
662
  }
649
663
 
664
+ /* Two buttons in a card's title row stand 8px apart (DqZaakContact "Live
665
+ gesprek", DqZaakEmail "Mail koppelen" / "Mail opstellen").
666
+ @spec openspec/changes/board-card-header-actions-gap/specs/board-card-header-actions-gap/spec.md#requirement-a-board-cards-title-row-buttons-stand-8px-apart */
667
+ .cn-look-board .cn-widget-wrapper__actions:not(.cn-look-nextcloud *) {
668
+ gap: 8px;
669
+ }
670
+
650
671
  /* A detail page marked `cardButtons: "regular"` draws them at the 40px
651
672
  control height: the case page's tab cards (DqZaakContact "Live gesprek",
652
673
  DqZaakHistorie, DqZaakBetrokkenen), where setting and case-type pages keep 34.
@@ -2983,8 +3004,8 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .v-popper__popper.action-
2983
3004
 
2984
3005
  /* A dialog's options as the boards draw them (DqVolgendeStap, DqBesluit,
2985
3006
  DqDocumentGenereren, DqFilterBewerken): rounded at 10px, an idle option
2986
- edged in the dark border grey, and an 18px radio. The chosen and the
2987
- refused options keep their own edges. */
3007
+ edged in the dark border grey, and an 18px radio. The chosen option keeps
3008
+ its own edge. */
2988
3009
  .cn-look-board .cn-choice-cards .cn-choice-cards__row:not(.cn-look-nextcloud *),
2989
3010
  .cn-look-board .cn-choice-cards .cn-choice-cards__cell--with-details:not(.cn-look-nextcloud *) {
2990
3011
  border-radius: 10px;
@@ -2994,6 +3015,14 @@ body:has(.cn-look-board):not(:has(.cn-look-nextcloud)) .v-popper__popper.action-
2994
3015
  border-color: var(--color-border-maxcontrast);
2995
3016
  }
2996
3017
 
3018
+ /* A refused option keeps that edge and sits on the sunken ground
3019
+ (DqVolgendeStap: Hervatten, Archiveren, Heropenen). */
3020
+ .cn-look-board .cn-choice-cards .cn-choice-cards__row--disabled:not(.cn-look-nextcloud *),
3021
+ .cn-look-board .cn-choice-cards .cn-choice-cards__row--disabled:hover:not(.cn-look-nextcloud *) {
3022
+ border-color: var(--color-border-maxcontrast);
3023
+ background: var(--cn-board-sunken-ground);
3024
+ }
3025
+
2997
3026
  /* A chosen option stays white under its primary edge, as every Dq dialog board
2998
3027
  draws it (design-system #371); the Nextcloud look keeps the tint.
2999
3028
  @spec openspec/changes/board-choice-row-chosen-white/specs/board-choice-row-chosen-white/spec.md#requirement-a-chosen-board-option-is-white */
package/src/css/table.css CHANGED
@@ -289,6 +289,19 @@
289
289
  white-space: nowrap;
290
290
  }
291
291
 
292
+ /* A growing column that opts into wrapping (`cn-cell--wrap`, DqDoorlooptijd
293
+ "Zaken met risico" titles) wraps its text over lines instead of cutting
294
+ it, and keeps a readable width: with `max-width: 0` the wide one-line
295
+ cells beside it squeezed it to its padding (24px).
296
+ @spec openspec/changes/fit-table-wrap-column-keeps-width/specs/fit-table-wrap-column-keeps-width/spec.md#requirement-a-wrapping-growing-column-keeps-a-readable-width */
297
+ .cn-data-table--fit th.cn-table-col--grow.cn-cell--wrap,
298
+ .cn-data-table--fit td.cn-table-col--grow.cn-cell--wrap {
299
+ min-width: 8em;
300
+ max-width: none;
301
+ overflow-wrap: anywhere;
302
+ white-space: normal;
303
+ }
304
+
292
305
  /* The secondary line of the growing column ("2026-0004" under a case title)
293
306
  is a line of its own with its own ellipsis. The cell does not wrap, so in
294
307
  the board look, where the secondary line is an inline block (the title
@@ -470,6 +470,27 @@ export function formatMasked(value, _row, _property, options) {
470
470
  return '*'.repeat(Math.min(text.length - visible, 5)) + (visible > 0 ? text.slice(-visible) : '')
471
471
  }
472
472
 
473
+ /**
474
+ * A stored value through a display map (`valueMap` registry key): the label
475
+ * `options.map` gives the value, else the value as stored. "Besloten
476
+ * Vennootschap" reads "BV" on DqOrganisaties.
477
+ *
478
+ * @param {unknown} value The stored value.
479
+ * @param {object} [_row] The full row (unused).
480
+ * @param {object} [_property] The schema property (unused).
481
+ * @param {{map?: Object<string, string>}} [options] The column's `formatterOptions`.
482
+ * @return {string} The label, or the value.
483
+ * @spec openspec/changes/value-map-formatter/specs/index-page/spec.md#requirement-a-column-can-show-stored-values-through-a-display-map
484
+ */
485
+ export function formatValueMap(value, _row, _property, options) {
486
+ if (value === null || value === undefined || value === '') {
487
+ return ''
488
+ }
489
+ const map = options && options.map && typeof options.map === 'object' ? options.map : {}
490
+ const label = Object.hasOwn(map, String(value)) ? map[String(value)] : undefined
491
+ return typeof label === 'string' && label !== '' ? label : String(value)
492
+ }
493
+
473
494
  /**
474
495
  * Built-in formatter registry merged under any consumer-registered
475
496
  * `formatters` in `CnAppRoot`'s `cnFormatters` provide.
@@ -516,4 +537,5 @@ export const BUILT_IN_FORMATTERS = {
516
537
  connectionSettingsLabel: formatConnectionSettingsLabel,
517
538
  masked: formatMasked,
518
539
  isoDuration: formatIsoDuration,
540
+ valueMap: formatValueMap,
519
541
  }
@@ -64,7 +64,7 @@ export function formatLongDate(value, now = new Date(), withYear = false) {
64
64
  * Format one token value.
65
65
  *
66
66
  * @param {unknown} value The raw value.
67
- * @param {string} format `date`, `datetime`, `weekday`, `longdate`, `time`, `user`, `currency`, `relative`, `relativetime`, `ago`, `week`, `days`, `lower` or '' (as is).
67
+ * @param {string} format `date`, `datetime`, `weekday`, `longdate`, `time`, `user`, `currency`, `relative`, `relativetime`, `ago`, `week`, `days`, `lower`, `mailbox` or '' (as is).
68
68
  * @param {Date} now The reference moment.
69
69
  * @param {string} [arg] The format's argument (`currency:EUR`, or a row path holding the code).
70
70
  * @param {object|null} [object] The row, for a currency read from a field.
@@ -125,6 +125,12 @@ function formatValue(value, format, now, arg = '', object = null, config = null,
125
125
  if (format === 'ago') {
126
126
  return formatAgo(value, now)
127
127
  }
128
+ if (format === 'mailbox') {
129
+ // An address up to and with its @ ("gemeente@" from gemeente@zuiddrecht.nl).
130
+ const text = String(value)
131
+ const at = text.indexOf('@')
132
+ return at > 0 ? text.slice(0, at + 1) : text
133
+ }
128
134
  if (format === 'lower') {
129
135
  return String(value).toLocaleLowerCase()
130
136
  }
@@ -30,12 +30,32 @@ let _source = null
30
30
  /** The read in flight, shared by every caller. */
31
31
  let _pending = null
32
32
 
33
+ /**
34
+ * The team the app answered, as kept: one group id, a list of two or more,
35
+ * or `''` for none. Blank entries are dropped and a list of one reads as that
36
+ * one id.
37
+ *
38
+ * @spec openspec/changes/my-team-source-may-answer-several-teams/specs/schema-utilities/spec.md#requirement-myteam-may-resolve-to-several-teams
39
+ * @param {unknown} team What the app's loader answered.
40
+ * @return {string|string[]} The kept team.
41
+ */
42
+ function normalizeTeam(team) {
43
+ if (Array.isArray(team)) {
44
+ const ids = team.filter((id) => typeof id === 'string').map((id) => id.trim()).filter((id) => id !== '')
45
+ if (ids.length === 0) {
46
+ return ''
47
+ }
48
+ return ids.length === 1 ? ids[0] : ids
49
+ }
50
+ return typeof team === 'string' ? team.trim() : ''
51
+ }
52
+
33
53
  /**
34
54
  * Name where the current user's primary team is read: a function answering
35
55
  * the team's Nextcloud group id, or null/'' for a user in no team.
36
56
  *
37
57
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
38
- * @param {(() => Promise<string|null>)|null} loader The app's loader, or null to unregister.
58
+ * @param {(() => Promise<string|string[]|null>)|null} loader The app's loader (a group id, or the ids of several teams), or null to unregister.
39
59
  * @return {void}
40
60
  */
41
61
  export function registerMyTeamSource(loader) {
@@ -49,7 +69,7 @@ export function registerMyTeamSource(loader) {
49
69
  * when no source is registered, or when the read fails.
50
70
  *
51
71
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
52
- * @return {Promise<string>} The team's group id, or ''.
72
+ * @return {Promise<string|string[]>} The team's group id (or ids), or ''.
53
73
  */
54
74
  export function loadMyTeam() {
55
75
  if (_state.team !== null) {
@@ -64,7 +84,7 @@ export function loadMyTeam() {
64
84
  }
65
85
  _pending = Promise.resolve()
66
86
  .then(() => _source())
67
- .then((team) => (typeof team === 'string' ? team.trim() : ''))
87
+ .then((team) => normalizeTeam(team))
68
88
  .catch(() => '')
69
89
  .then((team) => {
70
90
  _state.team = team
@@ -79,7 +99,7 @@ export function loadMyTeam() {
79
99
  * it is being read (and starts the read on first use).
80
100
  *
81
101
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
82
- * @return {string|null} The team, '' or null.
102
+ * @return {string|string[]|null} The team (or teams), '' or null.
83
103
  */
84
104
  export function peekMyTeam() {
85
105
  if (_state.team === null) {
@@ -89,15 +89,16 @@ export const MY_GROUPS_TOKEN = '@myGroups'
89
89
  export const MY_TEAM_TOKEN = '@myTeam'
90
90
 
91
91
  /**
92
- * Resolve `@myTeam`: the team's group id; `@myTeam` while it is being read (the
92
+ * Resolve `@myTeam`: the team's group id, or the ids of several teams when the
93
+ * app answers a list (an IN filter); `@myTeam` while it is being read (the
93
94
  * caller waits); `@myTeam?` for a user in no team (the caller drops the key).
94
95
  *
95
96
  * @spec openspec/changes/saved-view-tokens-my-team/specs/schema-utilities/spec.md#requirement-myteam-resolves-to-the-current-users-primary-team
96
- * @param {{myTeam?: string}} [ctx] Optional context; `ctx.myTeam` wins.
97
- * @return {string} The group id or the token.
97
+ * @param {{myTeam?: (string|string[])}} [ctx] Optional context; `ctx.myTeam` wins.
98
+ * @return {string|string[]} The group id(s) or the token.
98
99
  */
99
100
  function resolveMyTeam(ctx) {
100
- const team = ctx && typeof ctx.myTeam === 'string' ? ctx.myTeam : peekMyTeam()
101
+ const team = ctx && (typeof ctx.myTeam === 'string' || Array.isArray(ctx.myTeam)) ? ctx.myTeam : peekMyTeam()
101
102
  if (team === null) {
102
103
  return MY_TEAM_TOKEN
103
104
  }
@@ -223,6 +223,41 @@ function daysAfter(days, now) {
223
223
  return date
224
224
  }
225
225
 
226
+ /**
227
+ * Today plus that many days, as a date input's value.
228
+ *
229
+ * @param {unknown} days Whole days, 1 or more.
230
+ * @param {Date} [now] What counts as today.
231
+ * @return {string} `yyyy-mm-dd`, '' without a positive whole number.
232
+ * @spec openspec/changes/days-input-as-date/specs/transition-inputs/spec.md
233
+ */
234
+ export function dayAfterIso(days, now = new Date()) {
235
+ const date = daysAfter(days, now)
236
+ if (date === null) {
237
+ return ''
238
+ }
239
+ const pad = (number) => String(number).padStart(2, '0')
240
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
241
+ }
242
+
243
+ /**
244
+ * The whole days from today to a date input's value, at least 1.
245
+ *
246
+ * @param {unknown} iso A `yyyy-mm-dd` date.
247
+ * @param {Date} [now] What counts as today.
248
+ * @return {string} The days, '' without a date.
249
+ * @spec openspec/changes/days-input-as-date/specs/transition-inputs/spec.md
250
+ */
251
+ export function daysUntilIso(iso, now = new Date()) {
252
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso ?? '').trim())
253
+ if (!match) {
254
+ return ''
255
+ }
256
+ const target = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
257
+ const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
258
+ return String(Math.max(1, Math.round((target - today) / 86400000)))
259
+ }
260
+
226
261
  /**
227
262
  * A date as a reader sees it: day, short month and year.
228
263
  *
@@ -290,6 +325,9 @@ export function fillInputTemplate(template, values, now = new Date()) {
290
325
  * in that other input.
291
326
  * - `preview: { label, template, note, testid }`: a line under the input,
292
327
  * filled by `fillInputTemplate` and shown only once it resolves.
328
+ * - `dateField: { label, note, testid }`: on a days input, an editable date
329
+ * under it, today plus the days; a chosen date sets the days to the whole
330
+ * days from today, at least 1. `dateValue` carries the date.
293
331
  * - `default`: the starting value.
294
332
  * - Presentation: `labelOutside`, `id`, `note`, `wide`, `testid`, `rows`,
295
333
  * `placeholder`.
@@ -302,7 +340,7 @@ export function fillInputTemplate(template, values, now = new Date()) {
302
340
  * @param {string} [options.register] Register the reference pickers fall back to.
303
341
  * @param {object} [options.context] What `optionsSource` reads.
304
342
  * @param {object} [options.values] The values so far.
305
- * @param {Date} [options.now] What counts as today, for previews.
343
+ * @param {Date} [options.now] What counts as today, for previews and `dateField`.
306
344
  * @return {Array<object>} The descriptors.
307
345
  */
308
346
  export function resolveInputFields(declared, { schema = null, register = '', context = {}, values = {}, now } = {}) {
@@ -345,6 +383,9 @@ export function resolveInputFields(declared, { schema = null, register = '', con
345
383
  field.previewText = preview && typeof preview === 'object'
346
384
  ? fillInputTemplate(preview.template, values, now)
347
385
  : ''
386
+ field.dateValue = field.input.dateField && typeof field.input.dateField === 'object'
387
+ ? dayAfterIso(values[field.key], now)
388
+ : ''
348
389
  }
349
390
 
350
391
  return fields