@conduction/nextcloud-vue 2.139.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 (101) 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 +30 -2
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  36. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +28 -3
  37. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue.js +1 -1
  40. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js +10 -0
  41. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue2.js.map +1 -1
  42. package/dist/esm/components/CnRecordListWidget/CnRecordNoteRows.vue3.js.map +1 -1
  43. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue.js +1 -1
  44. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +12 -1
  45. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -1
  46. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +1 -1
  47. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js +1 -1
  49. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js +23 -1
  50. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js.map +1 -1
  51. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js +43 -13
  52. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js.map +1 -1
  53. package/dist/esm/composables/indexSources.js +5 -2
  54. package/dist/esm/composables/indexSources.js.map +1 -1
  55. package/dist/esm/composables/useTaskInboxStore.js +4 -3
  56. package/dist/esm/composables/useTaskInboxStore.js.map +1 -1
  57. package/dist/esm/l10n/en.json.js +2 -1
  58. package/dist/esm/l10n/en.json.js.map +1 -1
  59. package/dist/esm/l10n/nl.json.js +5 -1
  60. package/dist/esm/l10n/nl.json.js.map +1 -1
  61. package/dist/esm/library-style.js +1 -1
  62. package/dist/esm/nextcloud-vue.css +466 -371
  63. package/dist/esm/utils/builtInFormatters.js +23 -1
  64. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  65. package/dist/esm/utils/fillTemplate.js +7 -1
  66. package/dist/esm/utils/fillTemplate.js.map +1 -1
  67. package/dist/esm/utils/myTeam.js +24 -4
  68. package/dist/esm/utils/myTeam.js.map +1 -1
  69. package/dist/esm/utils/resolveFilterTokens.js +5 -4
  70. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  71. package/dist/esm/utils/transitionInputs.js +43 -2
  72. package/dist/esm/utils/transitionInputs.js.map +1 -1
  73. package/dist/nextcloud-vue.cjs.js +771 -222
  74. package/dist/nextcloud-vue.cjs.js.map +2 -2
  75. package/dist/nextcloud-vue.css +466 -371
  76. package/l10n/en.json +2 -1
  77. package/l10n/nl.json +5 -1
  78. package/package.json +1 -1
  79. package/src/components/CnAppRoot/CnAppRoot.vue +9 -2
  80. package/src/components/CnCellRenderer/CnCellRenderer.vue +19 -3
  81. package/src/components/CnChartWidget/CnChartWidget.md +1 -0
  82. package/src/components/CnChartWidget/CnChartWidget.vue +72 -1
  83. package/src/components/CnConversationThread/CnConversationWidget.vue +126 -1
  84. package/src/components/CnFactsWidget/CnFactsWidget.vue +72 -5
  85. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +28 -1
  86. package/src/components/CnFilesBrowser/filesBrowser.js +7 -1
  87. package/src/components/CnFilesBrowser/filesBrowserColumns.js +5 -0
  88. package/src/components/CnIndexPage/CnIndexPage.vue +30 -2
  89. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +28 -3
  90. package/src/components/CnRecordListWidget/CnRecordNoteRows.vue +10 -0
  91. package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +68 -2
  92. package/src/components/CnTransitionInputFields/CnTransitionInputFields.vue +43 -1
  93. package/src/composables/indexSources.js +5 -2
  94. package/src/composables/useTaskInboxStore.js +4 -3
  95. package/src/css/look-board.css +10 -2
  96. package/src/css/table.css +13 -0
  97. package/src/utils/builtInFormatters.js +22 -0
  98. package/src/utils/fillTemplate.js +7 -1
  99. package/src/utils/myTeam.js +24 -4
  100. package/src/utils/resolveFilterTokens.js +5 -4
  101. package/src/utils/transitionInputs.js +42 -1
package/l10n/en.json CHANGED
@@ -2380,7 +2380,8 @@
2380
2380
  "You save the order along with a view.": "You save the order along with a view.",
2381
2381
  "me": "me",
2382
2382
  "Open profile": "Open profile",
2383
- "Link a contact from your address book.": "Link a contact from your address book."
2383
+ "Link a contact from your address book.": "Link a contact from your address book.",
2384
+ "The value could not be shown.": "The value could not be shown."
2384
2385
  },
2385
2386
  "plurals": {
2386
2387
  "{count} member": [
package/l10n/nl.json CHANGED
@@ -2630,7 +2630,11 @@
2630
2630
  "You save the order along with a view.": "De sortering bewaart u mee in een weergave.",
2631
2631
  "me": "ik",
2632
2632
  "Open profile": "Profiel openen",
2633
- "Link a contact from your address book.": "Koppel een contact uit je adresboek."
2633
+ "Link a contact from your address book.": "Koppel een contact uit je adresboek.",
2634
+ "The value could not be shown.": "De waarde kon niet worden getoond.",
2635
+ "Save as draft": "Opslaan als concept",
2636
+ "Draft saved at {time}": "Concept opgeslagen om {time}",
2637
+ "The draft could not be saved": "Het concept kon niet worden opgeslagen"
2634
2638
  },
2635
2639
  "plurals": {
2636
2640
  "{count} member": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.139.0",
3
+ "version": "2.140.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": {
@@ -770,9 +770,10 @@ import { DEFAULT_FORGE, resolveForge } from '../../utils/forge.js'
770
770
  import { BUILT_IN_KB_PROVIDERS } from '../../utils/kbSearchProviders.js'
771
771
  import { createManifestTranslate } from '../../utils/manifestTranslate.js'
772
772
  import { installModalStack, uninstallModalStack } from '../../utils/modalStack.js'
773
+ import { loadMyTeam } from '../../utils/myTeam.js'
773
774
  import { OBJECTS_CHANGED_EVENT } from '../../utils/objectSignals.js'
774
775
  import { resolveBrand } from '../../utils/resolveBrand.js'
775
- import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
776
+ import { dropOptionalUnresolved, MY_TEAM_TOKEN, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
776
777
  import { pageOffersSavedViews } from '../../utils/savedViewHelpers.js'
777
778
  import { normalizeSavedViewMenu, SAVED_VIEW_MENU_KEY, SAVED_VIEW_MENU_PREFIX, syncSavedViewMenu, toggleSavedViewMenuEntry, withSavedViewMenu } from '../../utils/savedViewMenu.js'
778
779
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
@@ -4475,7 +4476,13 @@ export default {
4475
4476
  async _fetchMenuItemCount(item) {
4476
4477
  try {
4477
4478
  const { register, schema, filter } = item.count
4478
- const resolved = resolveFilterTokens(filter && typeof filter === 'object' ? filter : {}, {})
4479
+ const raw = filter && typeof filter === 'object' ? filter : {}
4480
+ // `@myTeam` counts as the list filters: once the team is read,
4481
+ // every team the app answers, and without one no team at all.
4482
+ if (Object.values(raw).some((value) => value === MY_TEAM_TOKEN || (Array.isArray(value) && value.includes(MY_TEAM_TOKEN)))) {
4483
+ await loadMyTeam()
4484
+ }
4485
+ const resolved = dropOptionalUnresolved(resolveFilterTokens(raw, {}))
4479
4486
  const url = generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(register)}/${encodeURIComponent(schema)}`)
4480
4487
  const { data } = await axios.get(url, { params: { _limit: 1, ...resolved } })
4481
4488
  const total = data?.total
@@ -56,7 +56,7 @@
56
56
  <!-- Built-in "label" widget: the value's label (an enum's translated
57
57
  label) as plain text, where a list wants words and no pill. -->
58
58
  <template v-else-if="widget === 'label'">
59
- <span v-if="hasValue" :title="rawTitle">{{ labelledValue }}</span>
59
+ <span v-if="hasValue" :title="rawTitle">{{ shownLabel }}</span>
60
60
  <span v-else class="cn-cell-renderer__dash">—</span>
61
61
  </template>
62
62
 
@@ -65,7 +65,7 @@
65
65
  shapes it; the colour stays keyed on the raw value. -->
66
66
  <template v-else-if="widget === 'badge'">
67
67
  <CnStatusBadge v-if="hasValue"
68
- :label="labelledValue"
68
+ :label="shownLabel"
69
69
  :variant="badgeVariant"
70
70
  :colorMap="badgeColorMap"
71
71
  :colorKey="badgeColorKey"
@@ -438,7 +438,7 @@ import { ageInDays, boardDateLocale, formatAge, formatBoardDate, formatRelativeD
438
438
  import { daysUntil, parseDateValue, resolveDateVariant } from '../../utils/dateVariant.js'
439
439
  import { pillLook } from '../../utils/detailActionModel.js'
440
440
  import { resolveDue } from '../../utils/dueText.js'
441
- import { formatBoardDateTime } from '../../utils/fillTemplate.js'
441
+ import { fillTemplate, formatBoardDateTime } from '../../utils/fillTemplate.js'
442
442
  import { formatMoney } from '../../utils/formatMetric.js'
443
443
  import { groupDisplayName, loadGroupDisplayName } from '../../utils/groupAutocomplete.js'
444
444
  import { objectFieldValue } from '../../utils/objectName.js'
@@ -1455,6 +1455,22 @@ export default {
1455
1455
  * @return {string}
1456
1456
  * @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-a-built-in-cell-widget-shows-an-enum-value-by-its-label
1457
1457
  */
1458
+ /**
1459
+ * What a `label` or `badge` cell shows: its label, worded by
1460
+ * `widgetProps.textTemplate` when given (translated, filled from the
1461
+ * row, `{value}` the label; DqPostvak "Lijkt op 2026-0079").
1462
+ *
1463
+ * @return {string}
1464
+ * @spec openspec/changes/cell-text-template/specs/cell-text-template/spec.md
1465
+ */
1466
+ shownLabel() {
1467
+ const template = this.widgetProps && this.widgetProps.textTemplate
1468
+ if (typeof template !== 'string' || template === '') {
1469
+ return this.labelledValue
1470
+ }
1471
+ return fillTemplate(this.cnTranslate(template), { ...(this.row || {}), value: this.labelledValue })
1472
+ },
1473
+
1458
1474
  labelledValue() {
1459
1475
  if (!this.hasFormatter && this.isEnum && this.hasValue && typeof this.value !== 'object') {
1460
1476
  return this.enumLabel
@@ -109,6 +109,7 @@ Fallback slot — shown when ApexCharts is not available:
109
109
  | `barMax` | Number | `null` | `bar-list`: the value a full track stands for (100 for percentages); null takes the largest value |
110
110
  | `matrixLabelWidth` | Number | `null` | `matrix`: the row-label column's width in px; a long label wraps (DqProcesanalyse "Dagen per fase": 240). Null sizes the column to its longest label, at least 120px |
111
111
  | `footnote` | String | `''` | A line under the chart, through the host's translate: under the rows of a `bar-list` or `matrix`, under the plot of a `line`, `area` or `bar` chart |
112
+ | `notice` | Object | `null` | A toned note card under the chart: `{ lead?, text?, values?, labels?, tone? }`. `{name}` in `lead` (bold) and `text` is filled from `values[name]`, a literal or a record token (`@object.<field>`); `labels[name]` (`"rows"`, `"columns"`, `"values"` or a map) turns a key into words. `tone`: info, success, warning, error. Hidden while a value is missing |
112
113
  | `referenceLines` | Array | `[]` | Dashed horizontal reference lines on the value axis of a line, area or bar chart, such as a norm: `[{ value, label?, color? }]`. The label goes through the host's translate; the colour defaults to the theme's error colour (board DqDoorlooptijd). `valuePath` reads the value from the endpoint answer instead, `{value}` in the label writes it, and a line without a value is not drawn. |
113
114
  | `categoryFormat` | String | `''` | How date categories read on the x-axis: `month` ("jan") or `month-year` ("jan 2026"), in the user's language, or `week` for ISO week keys ("2026-W40" as "w40"). Categories that are not dates are left as is; a bare year key stays a year. |
114
115
  | `emptyCellLabel` | String | `''` | `matrix`: the text of a cell without a value ("geen"), through the host's translate. Empty leaves the cell blank |
@@ -148,12 +148,25 @@
148
148
  data-testid="cn-chart-widget-footnote">
149
149
  {{ hostTranslate(footnote) }}
150
150
  </p>
151
+ <!-- `notice`: a toned note card under the chart whose words carry
152
+ values from the page record (DqHandhaving "Advies: ..."). -->
153
+ <NcNoteCard
154
+ v-if="noticeParts"
155
+ :type="noticeParts.type"
156
+ :data-tone="noticeParts.type"
157
+ class="cn-chart-widget__notice"
158
+ data-testid="cn-chart-widget-notice">
159
+ <p class="cn-chart-widget__notice-text">
160
+ <strong v-if="noticeParts.lead">{{ noticeParts.lead }}</strong>{{ noticeParts.lead && noticeParts.text ? ' ' : '' }}{{ noticeParts.text }}
161
+ </p>
162
+ </NcNoteCard>
151
163
  </div>
152
164
  </template>
153
165
 
154
166
  <script>
155
167
  import { subscribe, unsubscribe } from '@nextcloud/event-bus'
156
168
  import { getLanguage, translate as t } from '@nextcloud/l10n'
169
+ import { NcNoteCard } from '@nextcloud/vue'
157
170
  import { inject, markRaw, ref } from 'vue'
158
171
  import VueApexCharts from 'vue3-apexcharts'
159
172
  import { useDataSource } from '../../composables/useDataSource.js'
@@ -405,7 +418,7 @@ function formatterFor(vf) {
405
418
  export default {
406
419
  name: 'CnChartWidget',
407
420
 
408
- components: { CnEndpointNotice },
421
+ components: { CnEndpointNotice, NcNoteCard },
409
422
 
410
423
  inject: {
411
424
  /**
@@ -467,6 +480,24 @@ export default {
467
480
  default: null,
468
481
  },
469
482
 
483
+ /**
484
+ * A toned note card under the chart whose words carry values from the
485
+ * page record: `{ lead?, text?, values?, labels?, tone? }`. `lead`
486
+ * (bold) and `text` go through the host's translate, then each
487
+ * `{name}` is filled from `values[name]`, a literal or a token such as
488
+ * `@object.recommendedIntervention`. `labels[name]` turns a key into
489
+ * words: `"rows"`, `"columns"` or `"values"` read the matrix's
490
+ * cellSource label maps, an object is its own map. `tone` is info
491
+ * (default), success, warning or error. Hidden while a value is
492
+ * missing, so a half-filled sentence never shows.
493
+ *
494
+ * @spec openspec/changes/chart-notice/specs/chart-notice/spec.md#requirement-a-chart-can-carry-a-notice-with-record-values
495
+ */
496
+ notice: {
497
+ type: Object,
498
+ default: null,
499
+ },
500
+
470
501
  /**
471
502
  * A line under the chart ("Klik een balk om de zaken te zien"),
472
503
  * through the host's translate: under the rows of a `bar-list` or
@@ -1601,6 +1632,38 @@ export default {
1601
1632
  }
1602
1633
  },
1603
1634
 
1635
+ /**
1636
+ * The notice's bold lead, text and note-card type, values filled in;
1637
+ * null when there is none or a value is missing.
1638
+ *
1639
+ * @return {{lead: string, text: string, type: string}|null} The notice.
1640
+ * @spec openspec/changes/chart-notice/specs/chart-notice/spec.md#requirement-a-chart-can-carry-a-notice-with-record-values
1641
+ */
1642
+ noticeParts() {
1643
+ const def = this.notice
1644
+ if (!def || typeof def !== 'object' || (!def.lead && !def.text)) {
1645
+ return null
1646
+ }
1647
+ const declared = def.values && typeof def.values === 'object' ? def.values : {}
1648
+ const resolved = resolveFilterTokens(declared, this.chartTokenCtx())
1649
+ const source = this.cellSource && typeof this.cellSource === 'object' ? this.cellSource : {}
1650
+ const maps = { rows: source.rowLabels, columns: source.columnLabels, values: source.valueLabels }
1651
+ const words = {}
1652
+ for (const name of Object.keys(declared)) {
1653
+ const raw = resolved[name]
1654
+ if (raw === null || raw === undefined || raw === '' || (typeof raw === 'string' && raw.charAt(0) === '@')) {
1655
+ return null
1656
+ }
1657
+ const ref = def.labels && def.labels[name]
1658
+ const map = typeof ref === 'string' ? maps[ref] : ref
1659
+ const label = map && typeof map === 'object' && typeof map[String(raw)] === 'string' ? map[String(raw)] : null
1660
+ words[name] = label ? this.hostTranslate(label) : String(raw)
1661
+ }
1662
+ const fill = (key) => (key ? this.hostTranslate(key).replace(/\{(\w+)\}/g, (match, name) => (name in words ? words[name] : match)) : '')
1663
+ const type = ['info', 'success', 'warning', 'error'].includes(def.tone) ? def.tone : 'info'
1664
+ return { lead: fill(def.lead), text: fill(def.text), type }
1665
+ },
1666
+
1604
1667
  /**
1605
1668
  * The matrix cell of the record (`highlight: { row, column }`, tokens
1606
1669
  * such as `@object.behaviour` resolved), as row and column indexes. A
@@ -2948,6 +3011,14 @@ export default {
2948
3011
  margin: 0;
2949
3012
  }
2950
3013
 
3014
+ .cn-chart-widget__notice {
3015
+ margin: 12px 0 0;
3016
+ }
3017
+
3018
+ .cn-chart-widget__notice-text {
3019
+ margin: 0;
3020
+ }
3021
+
2951
3022
  /* Under an ApexCharts plot the footnote is a sibling of the canvas, so it keeps
2952
3023
  its own line height while the canvas takes the rest of a fitted column. */
2953
3024
  .cn-chart-widget > .cn-chart-widget__footnote {
@@ -6,6 +6,7 @@
6
6
  <template>
7
7
  <div class="cn-case-widget-card">
8
8
  <CnConversationThread
9
+ ref="thread"
9
10
  :messages="messages"
10
11
  :title="tr(content.title || '')"
11
12
  :subtitle="tr(content.subtitle || '')"
@@ -34,7 +35,7 @@
34
35
  data-testid="cn-conversation-templates"
35
36
  @apply="applyTemplate($event, setDraft)" />
36
37
  </template>
37
- <template v-if="replyOptions.length > 0" #reply-actions>
38
+ <template v-if="replyOptions.length > 0 || draftUrl" #reply-actions="{ draft }">
38
39
  <NcCheckboxRadioSwitch
39
40
  v-for="option in replyOptions"
40
41
  :key="option.key"
@@ -42,6 +43,19 @@
42
43
  :data-testid="`cn-conversation-option-${option.key}`">
43
44
  {{ tr(option.label) }}
44
45
  </NcCheckboxRadioSwitch>
46
+ <span v-if="draftUrl && draftStatus"
47
+ class="cn-conversation-widget__draft-status"
48
+ role="status"
49
+ data-testid="cn-conversation-draft-status">
50
+ {{ draftStatus }}
51
+ </span>
52
+ <NcButton
53
+ v-if="draftUrl"
54
+ data-testid="cn-conversation-save-draft"
55
+ :disabled="savingDraft"
56
+ @click="saveDraft(draft)">
57
+ {{ t('nextcloud-vue', 'Save as draft') }}
58
+ </NcButton>
45
59
  </template>
46
60
  <template v-if="headerAction || statusBadgeText" #header-actions>
47
61
  <span v-if="statusBadgeText"
@@ -71,11 +85,13 @@
71
85
 
72
86
  <script>
73
87
  import { subscribe, unsubscribe } from '@nextcloud/event-bus'
88
+ import { translate as t } from '@nextcloud/l10n'
74
89
  import { NcButton, NcCheckboxRadioSwitch } from '@nextcloud/vue'
75
90
  import CnIcon from '../CnIcon/CnIcon.vue'
76
91
  import CnTemplateSelect from '../CnTemplateSelect/CnTemplateSelect.vue'
77
92
  import CnConversationThread from './CnConversationThread.vue'
78
93
  import { dispatchAction } from '../../utils/actionsDispatcher.js'
94
+ import { formatTimeOfDay } from '../../utils/boardDate.js'
79
95
  import { fetchReferenceObjects } from '../../utils/boardReferenceColumns.js'
80
96
  import { readPath } from '../../utils/readPath.js'
81
97
  import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
@@ -178,6 +194,10 @@ export default {
178
194
  acting: '',
179
195
  /** The threads `statusBadge` reads. */
180
196
  badgeRows: [],
197
+ /** What the last draft save said: when it was saved, or that it failed. */
198
+ draftStatus: '',
199
+ /** Whether a draft is being saved. */
200
+ savingDraft: false,
181
201
  }
182
202
  },
183
203
 
@@ -315,6 +335,21 @@ export default {
315
335
  * @return {string} The URL, or '' when the content names none.
316
336
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-conversation-thread
317
337
  */
338
+ /**
339
+ * The reply draft's url (`content.draft.url`, `@objectId` filled in),
340
+ * or '' without one.
341
+ *
342
+ * @return {string}
343
+ * @spec openspec/changes/conversation-reply-draft/specs/conversation-reply-draft/spec.md#requirement-a-reply-can-be-saved-as-a-draft-and-comes-back
344
+ */
345
+ draftUrl() {
346
+ const url = this.content.draft && this.content.draft.url
347
+ if (typeof url !== 'string' || url === '') {
348
+ return ''
349
+ }
350
+ return url.replace(/@objectId/g, encodeURIComponent(String(this.objectId ?? '')))
351
+ },
352
+
318
353
  endpoint() {
319
354
  const url = this.content.endpoint
320
355
  if (typeof url !== 'string' || url === '') {
@@ -340,19 +375,101 @@ export default {
340
375
  handler() {
341
376
  this.loadSource()
342
377
  this.loadBadge()
378
+ this.loadDraft()
343
379
  },
344
380
  },
345
381
  },
346
382
 
347
383
  mounted() {
348
384
  subscribe('cn:page:refresh', this.onPageRefresh)
385
+ subscribe('cn:conversation:reply-sent', this.onReplySentElsewhere)
349
386
  },
350
387
 
351
388
  beforeUnmount() {
352
389
  unsubscribe('cn:page:refresh', this.onPageRefresh)
390
+ unsubscribe('cn:conversation:reply-sent', this.onReplySentElsewhere)
353
391
  },
354
392
 
355
393
  methods: {
394
+ t,
395
+
396
+ /**
397
+ * Read the saved reply draft into the reply box.
398
+ *
399
+ * @return {Promise<void>}
400
+ * @spec openspec/changes/conversation-reply-draft/specs/conversation-reply-draft/spec.md#requirement-a-reply-can-be-saved-as-a-draft-and-comes-back
401
+ */
402
+ async loadDraft() {
403
+ if (!this.draftUrl) {
404
+ return
405
+ }
406
+ try {
407
+ const { default: axios } = await import('@nextcloud/axios')
408
+ const { generateUrl } = await import('@nextcloud/router')
409
+ const { data } = await axios.get(generateUrl(this.draftUrl))
410
+ const text = data && typeof data.text === 'string' ? data.text : ''
411
+ if (text !== '' && this.$refs.thread) {
412
+ this.$refs.thread.setDraft(text)
413
+ }
414
+ } catch {
415
+ // No saved draft to read leaves the box empty.
416
+ }
417
+ },
418
+
419
+ /**
420
+ * Store the reply box as the draft and say when it was saved.
421
+ *
422
+ * @param {string} text The reply box's text.
423
+ * @return {Promise<void>}
424
+ */
425
+ async saveDraft(text) {
426
+ this.savingDraft = true
427
+ try {
428
+ await this.storeDraft(String(text ?? ''))
429
+ this.draftStatus = t('nextcloud-vue', 'Draft saved at {time}', { time: formatTimeOfDay(new Date()) })
430
+ } catch {
431
+ this.draftStatus = t('nextcloud-vue', 'The draft could not be saved')
432
+ } finally {
433
+ this.savingDraft = false
434
+ }
435
+ },
436
+
437
+ /**
438
+ * A reply on this record went out through a dialog or another surface
439
+ * (`cn:conversation:reply-sent` with `{ objectId }`): the saved draft
440
+ * and the reply box are cleared.
441
+ *
442
+ * @param {{objectId?: string}} payload The record the reply was on.
443
+ * @return {Promise<void>}
444
+ * @spec openspec/changes/conversation-reply-draft/specs/conversation-reply-draft/spec.md#requirement-a-reply-can-be-saved-as-a-draft-and-comes-back
445
+ */
446
+ async onReplySentElsewhere(payload) {
447
+ if (!this.draftUrl || String(payload?.objectId ?? '') !== String(this.objectId ?? '')) {
448
+ return
449
+ }
450
+ if (this.$refs.thread) {
451
+ this.$refs.thread.setDraft('')
452
+ }
453
+ this.draftStatus = ''
454
+ try {
455
+ await this.storeDraft('')
456
+ } catch {
457
+ // The next save overwrites a draft that could not be cleared.
458
+ }
459
+ },
460
+
461
+ /**
462
+ * PUT the draft text.
463
+ *
464
+ * @param {string} text The text; '' clears the draft.
465
+ * @return {Promise<void>}
466
+ */
467
+ async storeDraft(text) {
468
+ const { default: axios } = await import('@nextcloud/axios')
469
+ const { generateUrl } = await import('@nextcloud/router')
470
+ await axios.put(generateUrl(this.draftUrl), { text })
471
+ },
472
+
356
473
  /**
357
474
  * Read the messages again when the page refreshes (a dialog sent one).
358
475
  *
@@ -691,6 +808,9 @@ export default {
691
808
  if (typeof text !== 'string' || text === '') {
692
809
  return
693
810
  }
811
+ if (this.draftUrl) {
812
+ this.storeDraft('')
813
+ }
694
814
  this.sentHere.push({
695
815
  id: `sent-${this.sentHere.length}`,
696
816
  time: new Date().toISOString(),
@@ -722,4 +842,9 @@ export default {
722
842
  border-radius: var(--border-radius-large);
723
843
  padding: calc(5 * var(--default-grid-baseline));
724
844
  }
845
+
846
+ .cn-conversation-widget__draft-status {
847
+ color: var(--color-text-maxcontrast);
848
+ font-size: 13px;
849
+ }
725
850
  </style>
@@ -66,19 +66,25 @@
66
66
  v-else-if="row.to"
67
67
  :to="row.to"
68
68
  class="cn-facts-widget__value-link">
69
- {{ row.masked && !revealed[row.key] ? row.masked : row.value }}
69
+ {{ shownValue(row) }}
70
70
  </router-link>
71
- <span v-else>{{ row.masked && !revealed[row.key] ? row.masked : row.value }}</span>
71
+ <span v-else>{{ shownValue(row) }}</span>
72
72
  <button
73
73
  v-if="row.reveal && !revealed[row.key]"
74
74
  type="button"
75
75
  class="cn-facts-widget__inline-button"
76
76
  :aria-label="revealLabel(row)"
77
77
  :data-testid="`cn-fact-reveal-${row.key}`"
78
+ :disabled="revealing === row.key"
78
79
  @click="reveal(row)">
79
80
  <EyeOutline :size="16" aria-hidden="true" />
80
81
  {{ showLabel }}
81
82
  </button>
83
+ <span
84
+ v-if="revealErrors[row.key]"
85
+ class="cn-facts-widget__reveal-error"
86
+ role="alert"
87
+ :data-testid="`cn-fact-reveal-error-${row.key}`">{{ revealErrors[row.key] }}</span>
82
88
  <button
83
89
  v-if="row.copy"
84
90
  type="button"
@@ -420,7 +426,7 @@ export default {
420
426
  * `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`), `pill` or `meter`.
421
427
  * `countFrom` (`{ register, schema, filter? }`, `@objectId` / `@object.<field>` in the filter) shows how many records that list holds ("Lopende zaken 43"); the row is left out while the count cannot be read.
422
428
  * A `meter` shows the amount at `path` against the limit at `limitPath` (or a constant `limit`) as "12 of 50" with a bar; past the limit the bar turns red and the text says "over the limit"; without a positive limit it shows the amount and no bar.
423
- * A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show). `copy: true` adds a copy button; `icon` replaces the label.
429
+ * A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show, which unmasks the record's value; `reveal: { url, path? }` makes Show ask the server for the value at `url` (tokens resolved), read at `path` (else the entry's path), so a server that answers the mask can check and record the reveal). `copy: true` adds a copy button; `icon` replaces the label.
424
430
  * A part may be `{ text }`, a constant; a `line` or `text` entry without `path` or `parts` shows its `text`.
425
431
  * `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.
426
432
  * `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.
@@ -474,6 +480,9 @@ export default {
474
480
  error: false,
475
481
  /** Keys of the masked rows the reader chose to show. */
476
482
  revealed: {},
483
+ revealedValues: {},
484
+ revealErrors: {},
485
+ revealing: '',
477
486
  /** Key of the row whose value was just copied. */
478
487
  copied: '',
479
488
  /** Labels of the referenced objects read so far, by `refKey`. */
@@ -1321,7 +1330,12 @@ export default {
1321
1330
  extras.external = true
1322
1331
  } else if (field.format === 'masked') {
1323
1332
  extras.masked = value.length > 4 ? '*'.repeat(Math.min(value.length - 4, 5)) + value.slice(-4) : value
1324
- extras.reveal = field.reveal === true && value.length > 4
1333
+ const server = field.reveal && typeof field.reveal === 'object' && typeof field.reveal.url === 'string' && field.reveal.url !== ''
1334
+ extras.reveal = (field.reveal === true || server) && value.length > 4
1335
+ if (server) {
1336
+ extras.revealUrl = field.reveal.url
1337
+ extras.revealPath = typeof field.reveal.path === 'string' && field.reveal.path !== '' ? field.reveal.path : (field.path || '')
1338
+ }
1325
1339
  }
1326
1340
  return extras
1327
1341
  },
@@ -1333,7 +1347,14 @@ export default {
1333
1347
  * @param {object} row The row.
1334
1348
  * @spec openspec/changes/screens-relation-pages/specs/relation-pages/spec.md#requirement-the-facts-card-draws-contact-details-identity-and-a-lead
1335
1349
  */
1336
- reveal(row) {
1350
+ async reveal(row) {
1351
+ if (row.revealUrl) {
1352
+ const value = await this.fetchRevealed(row)
1353
+ if (value === null) {
1354
+ return
1355
+ }
1356
+ this.revealedValues = { ...this.revealedValues, [row.key]: value }
1357
+ }
1337
1358
  this.revealed = { ...this.revealed, [row.key]: true }
1338
1359
  const detail = { path: row.path }
1339
1360
  /**
@@ -1344,6 +1365,47 @@ export default {
1344
1365
  emit(FACTS_REVEAL_CHANNEL, { ...detail, objectId: this.tokenCtx.objectId, register: this.tokenCtx.register || null, schema: this.tokenCtx.schema || null })
1345
1366
  },
1346
1367
 
1368
+ /**
1369
+ * The text a row shows: its mask until revealed, then the value the
1370
+ * server answered (a server reveal) or the value the record holds.
1371
+ *
1372
+ * @param {object} row The row.
1373
+ * @return {string}
1374
+ * @spec openspec/changes/facts-reveal-from-server/specs/facts-widget/spec.md#requirement-a-masked-fact-can-be-revealed-by-the-server
1375
+ */
1376
+ shownValue(row) {
1377
+ if (!row.masked || !this.revealed[row.key]) {
1378
+ return row.masked || row.value
1379
+ }
1380
+ return Object.hasOwn(this.revealedValues, row.key) ? this.revealedValues[row.key] : row.value
1381
+ },
1382
+
1383
+ /**
1384
+ * Ask the server for a masked value (`reveal.url`, tokens resolved,
1385
+ * read at `reveal.path`), never from a cache: each show is a request
1386
+ * the server can check and record.
1387
+ *
1388
+ * @param {object} row The row.
1389
+ * @return {Promise<string|null>} The value, or null when it could not be read.
1390
+ * @spec openspec/changes/facts-reveal-from-server/specs/facts-widget/spec.md#requirement-a-masked-fact-can-be-revealed-by-the-server
1391
+ */
1392
+ async fetchRevealed(row) {
1393
+ this.revealing = row.key
1394
+ this.revealErrors = { ...this.revealErrors, [row.key]: '' }
1395
+ try {
1396
+ const payload = await fetchEndpointSource({ url: row.revealUrl, responsePath: row.revealPath }, this.tokenCtx, { force: true })
1397
+ if (payload === null || payload === undefined || payload === '' || typeof payload === 'object') {
1398
+ throw new Error('no value')
1399
+ }
1400
+ return String(payload)
1401
+ } catch {
1402
+ this.revealErrors = { ...this.revealErrors, [row.key]: t('nextcloud-vue', 'The value could not be shown.') }
1403
+ return null
1404
+ } finally {
1405
+ this.revealing = ''
1406
+ }
1407
+ },
1408
+
1347
1409
  /**
1348
1410
  * Copy a row's value to the clipboard.
1349
1411
  *
@@ -1955,6 +2017,11 @@ export default {
1955
2017
  font: inherit;
1956
2018
  }
1957
2019
 
2020
+ .cn-facts-widget__reveal-error {
2021
+ font-size: 13px;
2022
+ color: var(--color-text-error, var(--color-error));
2023
+ }
2024
+
1958
2025
  .cn-facts-widget__inline-button {
1959
2026
  background: var(--color-main-background);
1960
2027
  border: 1px solid var(--color-border-dark);
@@ -775,6 +775,7 @@ import CnIcon from '../CnIcon/CnIcon.vue'
775
775
  import { readUserPreference, writeUserPreference } from '../../composables/useUserPreferences.js'
776
776
  import { dispatchAction } from '../../utils/actionsDispatcher.js'
777
777
  import { fillTemplate } from '../../utils/fillTemplate.js'
778
+ import { readPath } from '../../utils/readPath.js'
778
779
  import { isNewTabClick, isRowMiddleClick, preventMiddleClickAutoscroll } from '../../utils/rowAuxClick.js'
779
780
  import { ACTIONS_NEEDING_THE_FILES_PAGE, crumbsFor, hostActionApplies, joinPath } from './filesBrowser.js'
780
781
  import {
@@ -3049,7 +3050,7 @@ export default {
3049
3050
  ? { path: this.currentPath }
3050
3051
  : { fileId: node.fileid, fileName: node.basename, path: node.path }
3051
3052
  if (type === 'open-modal') {
3052
- wrapped = { ...action, props: { ...(action.props || {}), ...context } }
3053
+ wrapped = { ...action, props: { ...(action.props || {}), ...context, ...this.rowPropsOf(action, node) } }
3053
3054
  } else if (type === 'handler') {
3054
3055
  wrapped = { ...action, args: [...(action.args || []), node ?? this.folder] }
3055
3056
  }
@@ -3060,6 +3061,32 @@ export default {
3060
3061
  dispatchAction(wrapped, { router: this.$router || null })
3061
3062
  },
3062
3063
 
3064
+ /**
3065
+ * The clicked file's row-data fields an action asks for as modal props
3066
+ * (`rowProps: { prop: field }`), or none.
3067
+ *
3068
+ * @param {object} action The declared action.
3069
+ * @param {object|null} node The file, or null for a New-menu entry.
3070
+ * @return {object} The props.
3071
+ * @spec openspec/changes/files-review-row-when/specs/files-review-row-when/spec.md#requirement-a-modal-action-can-carry-fields-of-the-clicked-files-row-data
3072
+ */
3073
+ rowPropsOf(action, node) {
3074
+ const wanted = action && action.rowProps
3075
+ if (node === null || !wanted || typeof wanted !== 'object') {
3076
+ return {}
3077
+ }
3078
+ const byId = this.resolvedRowData && typeof this.resolvedRowData === 'object' ? this.resolvedRowData : {}
3079
+ const entry = byId[node.fileid] ?? byId[String(node.fileid)] ?? {}
3080
+ const out = {}
3081
+ for (const [prop, field] of Object.entries(wanted)) {
3082
+ const value = readPath(entry, String(field))
3083
+ if (value !== undefined && value !== null && value !== '') {
3084
+ out[prop] = value
3085
+ }
3086
+ }
3087
+ return out
3088
+ },
3089
+
3063
3090
  /**
3064
3091
  * A click on a linked row opens the file where it lives.
3065
3092
  *
@@ -285,13 +285,19 @@ export function indexRowData(data, { path = '', key = 'fileId' } = {}) {
285
285
  * `visibleIf.extension` lists file extensions (no case, a leading dot is
286
286
  * allowed); `visibleIf.mime` lists mime types, where one ending in `/` or
287
287
  * `/*` names the whole family. Every condition named must hold. An action
288
- * without `visibleIf` is offered on every file, as before.
288
+ * without `visibleIf` is offered on every file, as before. An action with
289
+ * `when` is offered only while `when` is `true`.
289
290
  *
290
291
  * @param {object} action The declared action.
291
292
  * @param {object} node The file (`basename`, `mime`).
292
293
  * @return {boolean} True when the action belongs on this file.
293
294
  */
294
295
  export function hostActionApplies(action, node) {
296
+ // `when`: the action applies only while it is true (a case type flag).
297
+ // @spec openspec/changes/files-review-row-when/specs/files-review-row-when/spec.md#requirement-a-files-browser-entry-can-apply-only-when-a-condition-holds
298
+ if (action && typeof action === 'object' && 'when' in action && action.when !== true) {
299
+ return false
300
+ }
295
301
  const condition = action && action.visibleIf
296
302
  if (!condition || typeof condition !== 'object') {
297
303
  return true
@@ -78,6 +78,11 @@ export function normaliseFileColumns(columns) {
78
78
  if (!entry || typeof entry !== 'object' || typeof entry.key !== 'string' || entry.key === '') {
79
79
  return
80
80
  }
81
+ // `when`: the column applies only while it is true (a case type flag).
82
+ // @spec openspec/changes/files-review-row-when/specs/files-review-row-when/spec.md#requirement-a-files-browser-entry-can-apply-only-when-a-condition-holds
83
+ if ('when' in entry && entry.when !== true) {
84
+ return
85
+ }
81
86
  if (isBuiltInFileColumn(entry.key) && entry.source === undefined) {
82
87
  out.push({ ...BUILT_IN_FILE_COLUMNS[entry.key], ...entry, builtIn: true })
83
88
  return