@conduction/nextcloud-vue 2.132.0 → 2.134.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 (107) hide show
  1. package/dist/esm/components/CnActionButtons/CnActionButtons.vue.js +1 -1
  2. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js +14 -0
  3. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js +5 -4
  5. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js.map +1 -1
  6. package/dist/esm/components/CnConditionListEditor/CnConditionListEditor.vue.js +9 -0
  7. package/dist/esm/components/CnConditionListEditor/CnConditionListEditor.vue.js.map +1 -0
  8. package/dist/esm/components/CnConditionListEditor/CnConditionListEditor.vue2.js +237 -0
  9. package/dist/esm/components/CnConditionListEditor/CnConditionListEditor.vue2.js.map +1 -0
  10. package/dist/esm/components/CnConditionListEditor/CnConditionListEditor.vue3.js +103 -0
  11. package/dist/esm/components/CnConditionListEditor/CnConditionListEditor.vue3.js.map +1 -0
  12. package/dist/esm/components/CnConditionListEditor/conditionOperators.js +121 -0
  13. package/dist/esm/components/CnConditionListEditor/conditionOperators.js.map +1 -0
  14. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +12 -0
  15. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +1 -1
  17. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  18. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  19. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +85 -10
  20. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  21. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +60 -36
  22. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  23. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +8 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  26. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue.js +1 -1
  27. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue2.js +10 -0
  28. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue2.js.map +1 -1
  29. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue3.js.map +1 -1
  30. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue.js +1 -1
  31. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js +46 -7
  32. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js +1 -1
  34. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js.map +1 -1
  35. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue.js +1 -1
  36. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js +258 -6
  37. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js +283 -131
  39. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js.map +1 -1
  40. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  41. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +42 -2
  42. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  43. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  45. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +1 -1
  46. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  47. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  48. package/dist/esm/dialogs/CnPartyAddDialog.vue.js +9 -0
  49. package/dist/esm/dialogs/CnPartyAddDialog.vue.js.map +1 -0
  50. package/dist/esm/dialogs/CnPartyAddDialog.vue2.js +184 -0
  51. package/dist/esm/dialogs/CnPartyAddDialog.vue2.js.map +1 -0
  52. package/dist/esm/dialogs/CnPartyAddDialog.vue3.js +168 -0
  53. package/dist/esm/dialogs/CnPartyAddDialog.vue3.js.map +1 -0
  54. package/dist/esm/index.js +2 -0
  55. package/dist/esm/index.js.map +1 -1
  56. package/dist/esm/l10n/en.json.js +27 -1
  57. package/dist/esm/l10n/en.json.js.map +1 -1
  58. package/dist/esm/l10n/nl.json.js +27 -1
  59. package/dist/esm/l10n/nl.json.js.map +1 -1
  60. package/dist/esm/library-style.js +1 -1
  61. package/dist/esm/nextcloud-vue.css +353 -217
  62. package/dist/esm/store/plugins/liveUpdates.js +13 -4
  63. package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
  64. package/dist/esm/store/useObjectStore.js +28 -5
  65. package/dist/esm/store/useObjectStore.js.map +1 -1
  66. package/dist/esm/utils/detailActionModel.js +6 -0
  67. package/dist/esm/utils/detailActionModel.js.map +1 -1
  68. package/dist/esm/utils/listLenses.js +20 -1
  69. package/dist/esm/utils/listLenses.js.map +1 -1
  70. package/dist/esm/utils/objectParties.js +79 -1
  71. package/dist/esm/utils/objectParties.js.map +1 -1
  72. package/dist/esm/utils/objectRows.js +59 -0
  73. package/dist/esm/utils/objectRows.js.map +1 -0
  74. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  75. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  76. package/dist/nextcloud-vue.cjs.js +7460 -5803
  77. package/dist/nextcloud-vue.cjs.js.map +2 -2
  78. package/dist/nextcloud-vue.css +353 -217
  79. package/l10n/en.json +27 -1
  80. package/l10n/nl.json +27 -1
  81. package/package.json +1 -1
  82. package/src/components/CnActionButtons/CnActionButtons.vue +15 -0
  83. package/src/components/CnConditionListEditor/CnConditionListEditor.vue +346 -0
  84. package/src/components/CnConditionListEditor/conditionOperators.js +126 -0
  85. package/src/components/CnConditionListEditor/index.js +17 -0
  86. package/src/components/CnDetailPage/CnDetailPage.md +1 -0
  87. package/src/components/CnDetailPage/CnDetailPage.vue +13 -1
  88. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +105 -12
  89. package/src/components/CnIndexPage/CnIndexPage.vue +8 -1
  90. package/src/components/CnIntegrationWidget/CnIntegrationWidget.vue +10 -0
  91. package/src/components/CnNcWidgetWidget/CnNcWidgetWidget.vue +47 -8
  92. package/src/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue +401 -3
  93. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +42 -2
  94. package/src/components/CnTabsWidget/CnTabsWidget.vue +1 -1
  95. package/src/components/index.js +1 -0
  96. package/src/css/look-board-index.css +6 -0
  97. package/src/css/look-board.css +35 -7
  98. package/src/dialogs/CnPartyAddDialog.vue +309 -0
  99. package/src/index.js +2 -0
  100. package/src/schemas/app-manifest-v2.schema.json +17 -1
  101. package/src/store/plugins/liveUpdates.js +13 -4
  102. package/src/store/useObjectStore.js +28 -5
  103. package/src/utils/detailActionModel.js +6 -0
  104. package/src/utils/listLenses.js +20 -1
  105. package/src/utils/objectParties.js +78 -0
  106. package/src/utils/objectRows.js +55 -0
  107. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -5,6 +5,23 @@
5
5
 
6
6
  <template>
7
7
  <div class="cn-object-parties" data-testid="cn-object-parties">
8
+ <div v-if="canAdd" class="cn-object-parties__manage">
9
+ <NcButton data-testid="cn-object-parties-add" @click="adding = true">
10
+ <template #icon>
11
+ <Plus :size="20" />
12
+ </template>
13
+ {{ manageWord('add', 'label', t('nextcloud-vue', 'Add a party')) }}
14
+ </NcButton>
15
+ </div>
16
+
17
+ <NcNoteCard
18
+ v-if="manageError"
19
+ type="error"
20
+ role="alert"
21
+ data-testid="cn-object-parties-manage-error">
22
+ {{ manageError }}
23
+ </NcNoteCard>
24
+
8
25
  <NcLoadingIcon v-if="loading" :size="24" />
9
26
 
10
27
  <NcEmptyContent
@@ -13,6 +30,35 @@
13
30
  :description="label('failedDetail')"
14
31
  data-testid="cn-object-parties-failed" />
15
32
 
33
+ <div
34
+ v-else-if="isPrimaryPresentation"
35
+ class="cn-object-parties__main-card"
36
+ data-testid="cn-object-parties-main-card">
37
+ <div
38
+ v-if="mainParty"
39
+ class="cn-object-parties__main"
40
+ :data-party="mainParty.partyUuid"
41
+ data-testid="cn-object-parties-main">
42
+ <span class="cn-object-parties__main-initials" aria-hidden="true" data-testid="cn-object-parties-main-initials">{{ initialsOf(nameOf(mainParty)) }}</span>
43
+ <span class="cn-object-parties__main-text">
44
+ <strong class="cn-object-parties__main-name" data-testid="cn-object-parties-main-name">{{ nameOf(mainParty) }}</strong>
45
+ <span v-if="mainRole" class="cn-object-parties__muted cn-object-parties__main-role" data-testid="cn-object-parties-main-role">{{ mainRole }}</span>
46
+ </span>
47
+ </div>
48
+ <span v-else class="cn-object-parties__muted" data-testid="cn-object-parties-main-none">{{ label('noPrimary') }}</span>
49
+ <a
50
+ v-if="mainParty && mainParty.email"
51
+ class="cn-object-parties__main-email"
52
+ :href="`mailto:${mainParty.email}`"
53
+ data-testid="cn-object-parties-main-email">
54
+ <EmailOutline :size="16" aria-hidden="true" />{{ mainParty.email }}</a>
55
+ <a
56
+ v-if="footLink"
57
+ class="cn-object-parties__main-link"
58
+ :href="footLink.href"
59
+ data-testid="cn-object-parties-main-link">{{ footLink.label }}</a>
60
+ </div>
61
+
16
62
  <NcEmptyContent
17
63
  v-else-if="groups.length === 0"
18
64
  :name="label('empty')"
@@ -99,29 +145,76 @@
99
145
  :data-effect="indicator.effect"
100
146
  :title="indicator.verdict"
101
147
  data-testid="cn-object-parties-indicator">{{ indicator.label }} · {{ indicator.verdict }}</span>
148
+ <NcButton
149
+ v-if="canRemove && party.partyUuid"
150
+ class="cn-object-parties__remove"
151
+ variant="tertiary"
152
+ :aria-label="removeLabel(party, group)"
153
+ :title="removeLabel(party, group)"
154
+ data-testid="cn-object-parties-remove"
155
+ @click="removing = { party, group }">
156
+ <template #icon>
157
+ <TrashCanOutline :size="20" />
158
+ </template>
159
+ </NcButton>
102
160
  </li>
103
161
  </ul>
104
162
  </section>
105
163
  </template>
164
+
165
+ <CnPartyAddDialog
166
+ v-if="adding"
167
+ ref="addDialog"
168
+ :dialogTitle="manageWord('add', 'dialogTitle', t('nextcloud-vue', 'Add a party'))"
169
+ :roles="addRoles"
170
+ :sources="addSources"
171
+ :createFields="createFields"
172
+ :createLabel="manageWord('add', 'createLabel', t('nextcloud-vue', 'Someone new'))"
173
+ :allowPrimary="content.manage.add.primary === true"
174
+ :primaryLabel="label('primary')"
175
+ :confirmLabel="manageWord('add', 'confirmLabel', t('nextcloud-vue', 'Add'))"
176
+ @close="adding = false"
177
+ @confirm="addParty" />
178
+
179
+ <CnConfirmDialog
180
+ v-if="removing"
181
+ ref="removeDialog"
182
+ :dialogTitle="manageWord('remove', 'title', t('nextcloud-vue', 'Remove party'))"
183
+ :message="removeMessage"
184
+ :confirmLabel="manageWord('remove', 'confirmLabel', t('nextcloud-vue', 'Remove'))"
185
+ variant="error"
186
+ @close="removing = null"
187
+ @confirm="removeParty" />
106
188
  </div>
107
189
  </template>
108
190
 
109
191
  <script>
110
192
  import axios from '@nextcloud/axios'
193
+ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
111
194
  import { translatePlural as n, translate as t } from '@nextcloud/l10n'
112
195
  import { generateUrl } from '@nextcloud/router'
113
- import { NcEmptyContent, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
196
+ import { NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
197
+ import EmailOutline from 'vue-material-design-icons/EmailOutline.vue'
198
+ import Plus from 'vue-material-design-icons/Plus.vue'
199
+ import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
200
+ import CnConfirmDialog from '../../dialogs/CnConfirmDialog.vue'
201
+ import CnPartyAddDialog from '../../dialogs/CnPartyAddDialog.vue'
114
202
  import { interpolateUrlTokens } from '../../composables/useEndpointSource.js'
115
203
  import innerHeading from '../../mixins/innerHeading.js'
116
204
  import { conditionalLine } from '../../utils/conditionalLine.js'
205
+ import { initialsOf } from '../../utils/initials.js'
117
206
  import {
118
207
  documentsOfParty,
119
208
  indicatorsOf,
120
209
  indicatorVerdict,
121
210
  kindCode,
211
+ newPartyRecord,
122
212
  representedByMap,
213
+ roleLabel,
123
214
  rolesInOrder,
215
+ savedUuid,
124
216
  } from '../../utils/objectParties.js'
217
+ import { fetchCallerScope } from '../../utils/objectPermissions.js'
125
218
  import { readPath } from '../../utils/readPath.js'
126
219
 
127
220
  let uid = 0
@@ -141,6 +234,7 @@ function defaultLabels() {
141
234
  primary: t('nextcloud-vue', 'Primary party'),
142
235
  notAccepted: t('nextcloud-vue', 'Kind not accepted here'),
143
236
  representedBy: t('nextcloud-vue', 'Represented by'),
237
+ noPrimary: t('nextcloud-vue', 'No main contact yet'),
144
238
  }
145
239
  }
146
240
 
@@ -164,6 +258,22 @@ function defaultLabels() {
164
258
  * }
165
259
  * ```
166
260
  *
261
+ * `manage` lets a reader change the parties through OpenRegister's party
262
+ * API, which asks the record's update right on every write: `add` (`{ label?,
263
+ * dialogTitle?, kind?, sources, create?, primary?, confirmLabel? }`) opens a
264
+ * dialog to pick somebody known or describe somebody new (`create: {
265
+ * register, schema, label?, fields, compose? }`, see `newPartyRecord`), choose
266
+ * a role and optionally make them the primary party; `remove` (`{ title?,
267
+ * message?, confirmLabel? }`, `{name}` and `{role}` in the message) takes a
268
+ * party off in one role. Both are hidden when OpenRegister says the reader
269
+ * may not update the schema; a refusal is shown, never swallowed.
270
+ *
271
+ * `presentation: "primary"` draws only the primary party, as a side card
272
+ * does: initials, name and role label, its correspondence e-mail, and
273
+ * `link` (`{ label, href? | tab? }`, `{total}` = the number of parties) at
274
+ * the foot; `tab` opens that tab of the page through the route hash. It
275
+ * reads the listing only.
276
+ *
167
277
  * A failed read says so and is never shown as "no parties".
168
278
  *
169
279
  * @spec openspec/changes/dq-abstract-record-widgets/specs/record-widgets/spec.md#requirement-a-record-shows-its-parties-by-role
@@ -171,7 +281,7 @@ function defaultLabels() {
171
281
  export default {
172
282
  name: 'CnObjectPartiesWidget',
173
283
 
174
- components: { NcEmptyContent, NcLoadingIcon, NcNoteCard },
284
+ components: { CnConfirmDialog, CnPartyAddDialog, EmailOutline, NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard, Plus, TrashCanOutline },
175
285
 
176
286
  mixins: [innerHeading],
177
287
 
@@ -188,7 +298,10 @@ export default {
188
298
  * `kindsSource` (`{ url, enforce?, sourceText? }`), `extraLines`
189
299
  * (`[{ endpoint, path?, valueLabels?, lines }]`, one request per
190
300
  * party, `{party}` filled in), `correspondence` (`{ endpoint, path?,
191
- * senderField?, recipientsField? }`) and `labels`.
301
+ * senderField?, recipientsField? }`), `labels`, `presentation`
302
+ * (`"primary"`: only the primary party) and `link` (`{ label,
303
+ * href? | tab? }`, the primary presentation's foot link) and
304
+ * `manage` (`{ add?, remove? }`, adding and removing parties).
192
305
  */
193
306
  content: {
194
307
  type: Object,
@@ -226,6 +339,10 @@ export default {
226
339
  kinds: null,
227
340
  extra: {},
228
341
  documents: [],
342
+ mayUpdate: true,
343
+ adding: false,
344
+ removing: null,
345
+ manageError: '',
229
346
  }
230
347
  },
231
348
 
@@ -235,6 +352,98 @@ export default {
235
352
  return rolesInOrder(this.listing, this.translatedMap(this.content?.roleLabels))
236
353
  },
237
354
 
355
+ /** @return {object} The `manage` declaration, or an empty one. */
356
+ manage() {
357
+ const manage = this.content?.manage
358
+ return manage && typeof manage === 'object' ? manage : {}
359
+ },
360
+
361
+ /** @return {boolean} Whether the add button shows. */
362
+ canAdd() {
363
+ return Boolean(this.manage.add) && this.mayUpdate && !this.isPrimaryPresentation && !this.loading && !this.failed
364
+ },
365
+
366
+ /** @return {boolean} Whether each party offers its remove button. */
367
+ canRemove() {
368
+ return Boolean(this.manage.remove) && this.mayUpdate && !this.isPrimaryPresentation
369
+ },
370
+
371
+ /** @return {Array<{key: string, label: string}>} The roles a new party may hold, in the reader's words. */
372
+ addRoles() {
373
+ const declared = Array.isArray(this.listing?.roles) ? this.listing.roles : []
374
+ const kind = this.manage.add?.kind
375
+ const accepted = kind ? (this.listing?.kinds || []).find((entry) => kindCode(entry) === kind)?.roles : null
376
+ const keys = Array.isArray(accepted) && accepted.length > 0 ? accepted : declared.map((role) => role?.key).filter(Boolean)
377
+ const own = this.translatedMap(this.content?.roleLabels)
378
+ return keys.map((key) => ({ key, label: own[key] || this.cnTranslate(roleLabel(key, declared, {})) }))
379
+ },
380
+
381
+ /** @return {Array<object>} The picker sources, their words translated by the picker. */
382
+ addSources() {
383
+ return Array.isArray(this.manage.add?.sources) ? this.manage.add.sources : []
384
+ },
385
+
386
+ /** @return {Array<object>} The new-person fields, labels translated. */
387
+ createFields() {
388
+ const fields = this.manage.add?.create?.fields
389
+ return (Array.isArray(fields) ? fields : [])
390
+ .filter((field) => field && field.key)
391
+ .map((field) => ({ ...field, label: this.cnTranslate(field.label || field.key) }))
392
+ },
393
+
394
+ /** @return {string} The question the remove dialog asks. */
395
+ removeMessage() {
396
+ if (!this.removing) {
397
+ return ''
398
+ }
399
+ const name = this.nameOf(this.removing.party)
400
+ const role = this.removing.group.label
401
+ const own = this.manage.remove?.message
402
+ return own
403
+ ? this.cnTranslate(own).replaceAll('{name}', name).replaceAll('{role}', role)
404
+ : t('nextcloud-vue', 'Remove {name} as {role}?', { name, role }, undefined, { escape: false })
405
+ },
406
+
407
+ /** @return {boolean} Whether only the primary party is drawn. */
408
+ isPrimaryPresentation() {
409
+ return this.content?.presentation === 'primary'
410
+ },
411
+
412
+ /** @return {object|null} The primary party's listing row. */
413
+ mainParty() {
414
+ if (!this.primary) {
415
+ return null
416
+ }
417
+ return (this.listing?.results || []).find((row) => row && row.partyUuid === this.primary) || null
418
+ },
419
+
420
+ /** @return {string} The primary party's role, in the app's words. */
421
+ mainRole() {
422
+ const key = this.mainParty?.role
423
+ if (!key) {
424
+ return ''
425
+ }
426
+ const own = this.translatedMap(this.content?.roleLabels)
427
+ if (typeof own[key] === 'string' && own[key] !== '') {
428
+ return own[key]
429
+ }
430
+ return this.cnTranslate(roleLabel(key, this.listing?.roles, {}))
431
+ },
432
+
433
+ /** @return {{label: string, href: string}|null} The primary presentation's foot link; none without parties. */
434
+ footLink() {
435
+ const link = this.content?.link
436
+ const total = Number(this.listing?.total ?? (this.listing?.results || []).length)
437
+ if (!link || typeof link !== 'object' || !link.label || !(total > 0)) {
438
+ return null
439
+ }
440
+ const href = link.tab ? `#${encodeURIComponent(link.tab)}` : (link.href || '')
441
+ if (href === '') {
442
+ return null
443
+ }
444
+ return { label: this.cnTranslate(link.label).replaceAll('{total}', String(total)), href }
445
+ },
446
+
238
447
  /** @return {string|null} The primary party's uuid. */
239
448
  primary() {
240
449
  return this.listing?.primary || null
@@ -300,7 +509,22 @@ export default {
300
509
  },
301
510
  },
302
511
 
512
+ mounted() {
513
+ this.onPageRefresh = (payload) => {
514
+ if (payload?.source !== this.idPrefix) {
515
+ this.load()
516
+ }
517
+ }
518
+ subscribe('cn:page:refresh', this.onPageRefresh)
519
+ },
520
+
521
+ beforeUnmount() {
522
+ unsubscribe('cn:page:refresh', this.onPageRefresh)
523
+ },
524
+
303
525
  methods: {
526
+ t,
527
+ initialsOf,
304
528
  kindCode,
305
529
 
306
530
  /**
@@ -314,6 +538,115 @@ export default {
314
538
  return own !== '' ? this.cnTranslate(own) : defaultLabels()[key]
315
539
  },
316
540
 
541
+ /**
542
+ * A word of the `manage` declaration, translated, else the default.
543
+ *
544
+ * @param {string} part `add` or `remove`.
545
+ * @param {string} key The word's key.
546
+ * @param {string} fallback The default.
547
+ * @return {string}
548
+ */
549
+ manageWord(part, key, fallback) {
550
+ const own = this.manage[part]?.[key]
551
+ return typeof own === 'string' && own !== '' ? this.cnTranslate(own) : fallback
552
+ },
553
+
554
+ /**
555
+ * The remove button's accessible name.
556
+ *
557
+ * @param {object} party The listing row.
558
+ * @param {object} group The role group.
559
+ * @return {string}
560
+ */
561
+ removeLabel(party, group) {
562
+ return t('nextcloud-vue', 'Remove {name} as {role}', { name: this.nameOf(party), role: group.label }, undefined, { escape: false })
563
+ },
564
+
565
+ /**
566
+ * Why a write failed, in the reader's words.
567
+ *
568
+ * @param {unknown} error The axios error.
569
+ * @return {string}
570
+ */
571
+ refusalOf(error) {
572
+ if (error?.response?.status === 403) {
573
+ return t('nextcloud-vue', 'You may not change who is on this record.')
574
+ }
575
+ const said = error?.response?.data?.error
576
+ return typeof said === 'string' && said !== '' ? said : t('nextcloud-vue', 'The change could not be saved. Nothing was changed.')
577
+ },
578
+
579
+ /** @return {string} The record's parties URL. */
580
+ partiesUrl() {
581
+ return this.urlOf(this.content?.rolesSource || '/apps/openregister/api/objects/@register/@schema/@objectId/parties')
582
+ },
583
+
584
+ /** @return {Promise<void>} Whether OpenRegister lets the reader update this schema; unknown counts as yes, the server decides. */
585
+ async loadMayUpdate() {
586
+ if (!this.manage.add && !this.manage.remove) {
587
+ return
588
+ }
589
+ const slug = (value) => (value && typeof value === 'object' ? String(value.slug || value.id || '') : String(value || ''))
590
+ const scope = await fetchCallerScope({ register: slug(this.register), schema: slug(this.schema) })
591
+ this.mayUpdate = !scope || !Array.isArray(scope.actions) || scope.actions.includes('update')
592
+ },
593
+
594
+ /**
595
+ * Create the person when they are new, then give them the role; a
596
+ * primary party is set through the primary route, which records the
597
+ * replacement.
598
+ *
599
+ * @param {{mode: string, partyUuid: string|null, values: object, role: string, primary: boolean}} choice The dialog's choice.
600
+ * @return {Promise<void>}
601
+ */
602
+ async addParty(choice) {
603
+ try {
604
+ let partyUuid = choice.partyUuid
605
+ if (choice.mode === 'new') {
606
+ const create = this.manage.add?.create || {}
607
+ const { data } = await axios.post(
608
+ generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(create.register || '')}/${encodeURIComponent(create.schema || '')}`),
609
+ newPartyRecord(create, choice.values),
610
+ )
611
+ partyUuid = savedUuid(data)
612
+ if (partyUuid === '') {
613
+ throw new Error(t('nextcloud-vue', 'The new person was not saved.'))
614
+ }
615
+ }
616
+ if (choice.primary) {
617
+ await axios.put(generateUrl(`${this.partiesUrl()}/primary`), { partyUuid, role: choice.role })
618
+ } else {
619
+ await axios.post(generateUrl(this.partiesUrl()), { partyUuid, role: choice.role })
620
+ }
621
+ this.adding = false
622
+ this.changed()
623
+ } catch (error) {
624
+ this.$refs.addDialog?.fail(error?.response ? this.refusalOf(error) : (error?.message || this.refusalOf(error)))
625
+ }
626
+ },
627
+
628
+ /** @return {Promise<void>} Take the chosen party off in the chosen role. */
629
+ async removeParty() {
630
+ const { party, group } = this.removing || {}
631
+ if (!party) {
632
+ return
633
+ }
634
+ try {
635
+ await axios.delete(generateUrl(`${this.partiesUrl()}/${encodeURIComponent(party.partyUuid)}`), { params: { role: group.key } })
636
+ this.removing = null
637
+ this.changed()
638
+ } catch (error) {
639
+ this.$refs.removeDialog?.setResult({ error: this.refusalOf(error) })
640
+ }
641
+ },
642
+
643
+ /** @return {void} Read the parties again and tell the page (tab counts, the side card). */
644
+ changed() {
645
+ this.manageError = ''
646
+ this.load()
647
+ emit('cn:page:refresh', { source: this.idPrefix })
648
+ },
649
+
317
650
  /**
318
651
  * A map of manifest words, translated.
319
652
  *
@@ -383,6 +716,11 @@ export default {
383
716
  return
384
717
  }
385
718
  this.listing = listing
719
+ await this.loadMayUpdate()
720
+ if (this.isPrimaryPresentation) {
721
+ this.loading = false
722
+ return
723
+ }
386
724
  const uuids = [...new Set((listing.results || []).map((row) => row && row.partyUuid).filter(Boolean))]
387
725
  const partyUrl = this.content?.partySource || '/apps/openregister/api/parties/{uuid}'
388
726
  const records = await Promise.all(uuids.map((uuid) => this.read(this.urlOf(partyUrl, uuid))))
@@ -657,6 +995,66 @@ export default {
657
995
  color: var(--color-text-maxcontrast);
658
996
  }
659
997
 
998
+ .cn-object-parties__manage {
999
+ display: flex;
1000
+ justify-content: flex-end;
1001
+ }
1002
+
1003
+ .cn-object-parties__remove {
1004
+ margin-inline-start: auto;
1005
+ }
1006
+
1007
+ .cn-object-parties__main-card {
1008
+ display: flex;
1009
+ flex-direction: column;
1010
+ gap: calc(var(--default-grid-baseline, 4px) * 3);
1011
+ }
1012
+
1013
+ .cn-object-parties__main {
1014
+ display: flex;
1015
+ align-items: center;
1016
+ gap: calc(var(--default-grid-baseline, 4px) * 3);
1017
+ }
1018
+
1019
+ .cn-object-parties__main-initials {
1020
+ display: flex;
1021
+ flex: none;
1022
+ align-items: center;
1023
+ justify-content: center;
1024
+ width: 40px;
1025
+ height: 40px;
1026
+ border-radius: 50%;
1027
+ background-color: var(--color-background-dark);
1028
+ color: var(--color-main-text);
1029
+ font-weight: 700;
1030
+ }
1031
+
1032
+ .cn-object-parties__main-text {
1033
+ display: flex;
1034
+ flex-direction: column;
1035
+ }
1036
+
1037
+ .cn-object-parties__main-name {
1038
+ font-size: 15px;
1039
+ }
1040
+
1041
+ .cn-object-parties__main-role {
1042
+ font-size: 13px;
1043
+ }
1044
+
1045
+ .cn-object-parties__main-email {
1046
+ display: flex;
1047
+ align-items: center;
1048
+ gap: calc(var(--default-grid-baseline, 4px) * 2.5);
1049
+ color: var(--color-main-text);
1050
+ overflow-wrap: anywhere;
1051
+ }
1052
+
1053
+ .cn-object-parties__main-link {
1054
+ color: var(--color-primary-element);
1055
+ font-weight: 600;
1056
+ }
1057
+
660
1058
  .cn-object-parties__accepted {
661
1059
  display: flex;
662
1060
  flex-wrap: wrap;
@@ -542,6 +542,7 @@ import { fillTemplate, firstFilled } from '../../utils/fillTemplate.js'
542
542
  import { unwrapAppConfig } from '../../utils/formatMetric.js'
543
543
  import { initialsOf } from '../../utils/initials.js'
544
544
  import { lensUnavailableText, readLensReports } from '../../utils/lensAvailability.js'
545
+ import { isObjectRowsSpec, objectRowsOf } from '../../utils/objectRows.js'
545
546
  import { pageFilter, pageSort } from '../../utils/pageControls.js'
546
547
  import { readPath } from '../../utils/readPath.js'
547
548
  import { dropOptionalUnresolved, hasUnresolvedTokens, resolveDeepTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
@@ -607,7 +608,8 @@ function unwrap(value) {
607
608
  * `object-list` (`register`, `schema`, `filter` with the `@objectId` /
608
609
  * `@object.<field>` / `@workspace.<key>` tokens, `sort`, `limit`, `extend`,
609
610
  * `rowRoute`, `pageControls`), or `endpointSource` (an app endpoint whose
610
- * payload, after `responsePath`, is the array of rows, as on the object table); `avatar` puts a circle with the initials of a name
611
+ * payload, after `responsePath`, is the array of rows, as on the object table), or `objectRows`
612
+ * (`{ path, where?, valuePath? }`: rows the page record holds, such as one answer of a case); `avatar` puts a circle with the initials of a name
611
613
  * template before the title; what differs is the drawing: `presentation` is `rows`, `feed`
612
614
  * or `inline`, and each line is a template (`"{identifier} · {caseTypeName}"`,
613
615
  * `"Termijn {deadline|date}"`). `cards` draws a framed card per row in a
@@ -647,7 +649,8 @@ export default {
647
649
  * with `overVariant: "error"`), or without `maxField` against the
648
650
  * largest row. `endpointSource` (`{ url, method?,
649
651
  * params?, responsePath? }`) reads the rows from an app endpoint instead
650
- * of `register` / `schema`. `valueLabels` maps a field's stored values to the labels
652
+ * of `register` / `schema`. `objectRows` (`{ path, where?, valuePath? }`)
653
+ * lists rows the page record holds, with no request. `valueLabels` maps a field's stored values to the labels
651
654
  * the templates show. `headerNote` and `headerLink.label` fill `{total}`
652
655
  * and `{count:<field>=<value>}`, the loaded rows with that value.
653
656
  * A row that is a plain string or number (an endpoint
@@ -960,6 +963,30 @@ export default {
960
963
  return Boolean(source && typeof source === 'object' && source.url)
961
964
  },
962
965
 
966
+ /**
967
+ * Whether the rows come from the page's own record (`objectRows`).
968
+ *
969
+ * @return {boolean}
970
+ * @spec openspec/changes/record-list-object-rows/specs/record-list/spec.md#requirement-a-record-list-can-list-rows-the-page-record-holds
971
+ */
972
+ objectRowsActive() {
973
+ return isObjectRowsSpec(this.content?.objectRows)
974
+ },
975
+
976
+ /**
977
+ * The rows `objectRows` names on the page record (see utils/objectRows.js).
978
+ *
979
+ * @return {Array<object>}
980
+ */
981
+ objectRowsList() {
982
+ return this.objectRowsActive ? objectRowsOf(this.objectData, this.content.objectRows) : []
983
+ },
984
+
985
+ /** @return {string} The page record's rows, to copy into the list when they change. */
986
+ objectRowsKey() {
987
+ return this.objectRowsActive ? JSON.stringify(this.objectRowsList) : ''
988
+ },
989
+
963
990
  /** @return {string} The endpoint read's state, to copy into the list when it changes. */
964
991
  endpointKey() {
965
992
  return this.endpointActive ? JSON.stringify([this.epData, this.epLoading, this.epError]) : ''
@@ -1464,6 +1491,10 @@ export default {
1464
1491
  this.fetchRows()
1465
1492
  },
1466
1493
 
1494
+ objectRowsKey() {
1495
+ this.fetchRows()
1496
+ },
1497
+
1467
1498
  linkIdsKey() {
1468
1499
  this.loadLinkLabels()
1469
1500
  },
@@ -2609,6 +2640,15 @@ export default {
2609
2640
  this.takeEndpointRows()
2610
2641
  return
2611
2642
  }
2643
+ if (this.objectRowsActive) {
2644
+ this.rows = this.flattenRows(this.objectRowsList.map((row, index) => (row.id === undefined ? { id: `row-${index}`, ...row } : row)))
2645
+ this.total = this.rows.length
2646
+ this.loading = false
2647
+ this.error = false
2648
+ this.lenses = {}
2649
+ this.emitHeader()
2650
+ return
2651
+ }
2612
2652
  if (!c.register || !c.schema || this.waiting) {
2613
2653
  this.rows = []
2614
2654
  this.total = 0
@@ -1056,7 +1056,7 @@ export default {
1056
1056
  * @spec openspec/changes/dq-abstract-record-widgets/specs/record-widgets/spec.md#requirement-opening-a-tab-marks-its-part-read
1057
1057
  */
1058
1058
  unreadLabelOf(tab, widgetId) {
1059
- if (!this.readStateBadges || (tab && typeof tab === 'object' && (typeof tab.count === 'number' || tab.countField))) {
1059
+ if (!this.readStateBadges || (tab && typeof tab === 'object' && (typeof tab.count === 'number' || tab.countField || tab.countObjectRows))) {
1060
1060
  return ''
1061
1061
  }
1062
1062
  const key = this.unreadKeyOf(tab, widgetId)
@@ -7,6 +7,7 @@ export { CnBulkJobProgress } from './CnBulkJobProgress/index.js'
7
7
  export { CnDataTable } from './CnDataTable/index.js'
8
8
  export { CnDataMatrix } from './CnDataMatrix/index.js'
9
9
  export { CnFilterBar } from './CnFilterBar/index.js'
10
+ export { CnConditionListEditor } from './CnConditionListEditor/index.js'
10
11
  export { CnCronField } from './CnCronField/index.js'
11
12
  export { CnFileField } from './CnFileField/index.js'
12
13
  export { CnOfflineQueue } from './CnOfflineQueue/index.js'
@@ -799,6 +799,12 @@
799
799
  padding-inline: 16px;
800
800
  }
801
801
 
802
+ /* The row's actions menu (a 34px toggle) adds no height to a card table's
803
+ row: the cell has no block padding, so the row stays at its text's 40px. */
804
+ .cn-look-board .cn-table-container--board:not(.cn-index-page__main--table > *) .cn-data-table .cn-table-col--actions {
805
+ padding-block: 0;
806
+ }
807
+
802
808
  .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table td > .cn-cell-renderer {
803
809
  display: block;
804
810
  min-width: 0;