@conduction/nextcloud-vue 2.133.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 (39) hide show
  1. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  2. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +85 -10
  3. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  4. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +60 -36
  5. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  6. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue.js +1 -1
  7. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue2.js +10 -0
  8. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue2.js.map +1 -1
  9. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue3.js.map +1 -1
  10. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue.js +1 -1
  11. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js +202 -6
  12. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js +137 -55
  14. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js.map +1 -1
  15. package/dist/esm/dialogs/CnPartyAddDialog.vue.js +9 -0
  16. package/dist/esm/dialogs/CnPartyAddDialog.vue.js.map +1 -0
  17. package/dist/esm/dialogs/CnPartyAddDialog.vue2.js +184 -0
  18. package/dist/esm/dialogs/CnPartyAddDialog.vue2.js.map +1 -0
  19. package/dist/esm/dialogs/CnPartyAddDialog.vue3.js +168 -0
  20. package/dist/esm/dialogs/CnPartyAddDialog.vue3.js.map +1 -0
  21. package/dist/esm/l10n/en.json.js +11 -1
  22. package/dist/esm/l10n/en.json.js.map +1 -1
  23. package/dist/esm/l10n/nl.json.js +11 -1
  24. package/dist/esm/l10n/nl.json.js.map +1 -1
  25. package/dist/esm/library-style.js +1 -1
  26. package/dist/esm/nextcloud-vue.css +95 -70
  27. package/dist/esm/utils/objectParties.js +79 -1
  28. package/dist/esm/utils/objectParties.js.map +1 -1
  29. package/dist/nextcloud-vue.cjs.js +5683 -4860
  30. package/dist/nextcloud-vue.cjs.js.map +2 -2
  31. package/dist/nextcloud-vue.css +95 -70
  32. package/l10n/en.json +11 -1
  33. package/l10n/nl.json +11 -1
  34. package/package.json +1 -1
  35. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +105 -12
  36. package/src/components/CnIntegrationWidget/CnIntegrationWidget.vue +10 -0
  37. package/src/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue +264 -3
  38. package/src/dialogs/CnPartyAddDialog.vue +309 -0
  39. package/src/utils/objectParties.js +78 -0
@@ -305,6 +305,15 @@ export default {
305
305
  */
306
306
  objectLabel: { type: String, default: '' },
307
307
 
308
+ /**
309
+ * Extra props for the leaf, set by the placement (a manifest widget's
310
+ * `props.placementProps`), for example the kinds a finance leaf shows. The
311
+ * record's own identity always wins over a key of the same name.
312
+ *
313
+ * @type {object}
314
+ */
315
+ placementProps: { type: Object, default: () => ({}) },
316
+
308
317
  /**
309
318
  * The surface the leaf itself is told it renders on, when it differs
310
319
  * from `surface` (a leaf placed in a dialog that draws its sidebar-tab
@@ -580,6 +589,7 @@ export default {
580
589
  */
581
590
  leafProps(provider) {
582
591
  return {
592
+ ...this.placementProps,
583
593
  integrationId: provider.id,
584
594
  register: this.resolvedRegister,
585
595
  schema: this.resolvedSchema,
@@ -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
@@ -128,19 +145,60 @@
128
145
  :data-effect="indicator.effect"
129
146
  :title="indicator.verdict"
130
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>
131
160
  </li>
132
161
  </ul>
133
162
  </section>
134
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" />
135
188
  </div>
136
189
  </template>
137
190
 
138
191
  <script>
139
192
  import axios from '@nextcloud/axios'
193
+ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
140
194
  import { translatePlural as n, translate as t } from '@nextcloud/l10n'
141
195
  import { generateUrl } from '@nextcloud/router'
142
- import { NcEmptyContent, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
196
+ import { NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
143
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'
144
202
  import { interpolateUrlTokens } from '../../composables/useEndpointSource.js'
145
203
  import innerHeading from '../../mixins/innerHeading.js'
146
204
  import { conditionalLine } from '../../utils/conditionalLine.js'
@@ -150,10 +208,13 @@ import {
150
208
  indicatorsOf,
151
209
  indicatorVerdict,
152
210
  kindCode,
211
+ newPartyRecord,
153
212
  representedByMap,
154
213
  roleLabel,
155
214
  rolesInOrder,
215
+ savedUuid,
156
216
  } from '../../utils/objectParties.js'
217
+ import { fetchCallerScope } from '../../utils/objectPermissions.js'
157
218
  import { readPath } from '../../utils/readPath.js'
158
219
 
159
220
  let uid = 0
@@ -197,6 +258,16 @@ function defaultLabels() {
197
258
  * }
198
259
  * ```
199
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
+ *
200
271
  * `presentation: "primary"` draws only the primary party, as a side card
201
272
  * does: initials, name and role label, its correspondence e-mail, and
202
273
  * `link` (`{ label, href? | tab? }`, `{total}` = the number of parties) at
@@ -210,7 +281,7 @@ function defaultLabels() {
210
281
  export default {
211
282
  name: 'CnObjectPartiesWidget',
212
283
 
213
- components: { EmailOutline, NcEmptyContent, NcLoadingIcon, NcNoteCard },
284
+ components: { CnConfirmDialog, CnPartyAddDialog, EmailOutline, NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard, Plus, TrashCanOutline },
214
285
 
215
286
  mixins: [innerHeading],
216
287
 
@@ -229,7 +300,8 @@ export default {
229
300
  * party, `{party}` filled in), `correspondence` (`{ endpoint, path?,
230
301
  * senderField?, recipientsField? }`), `labels`, `presentation`
231
302
  * (`"primary"`: only the primary party) and `link` (`{ label,
232
- * href? | tab? }`, the primary presentation's foot link).
303
+ * href? | tab? }`, the primary presentation's foot link) and
304
+ * `manage` (`{ add?, remove? }`, adding and removing parties).
233
305
  */
234
306
  content: {
235
307
  type: Object,
@@ -267,6 +339,10 @@ export default {
267
339
  kinds: null,
268
340
  extra: {},
269
341
  documents: [],
342
+ mayUpdate: true,
343
+ adding: false,
344
+ removing: null,
345
+ manageError: '',
270
346
  }
271
347
  },
272
348
 
@@ -276,6 +352,58 @@ export default {
276
352
  return rolesInOrder(this.listing, this.translatedMap(this.content?.roleLabels))
277
353
  },
278
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
+
279
407
  /** @return {boolean} Whether only the primary party is drawn. */
280
408
  isPrimaryPresentation() {
281
409
  return this.content?.presentation === 'primary'
@@ -381,7 +509,21 @@ export default {
381
509
  },
382
510
  },
383
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
+
384
525
  methods: {
526
+ t,
385
527
  initialsOf,
386
528
  kindCode,
387
529
 
@@ -396,6 +538,115 @@ export default {
396
538
  return own !== '' ? this.cnTranslate(own) : defaultLabels()[key]
397
539
  },
398
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
+
399
650
  /**
400
651
  * A map of manifest words, translated.
401
652
  *
@@ -465,6 +716,7 @@ export default {
465
716
  return
466
717
  }
467
718
  this.listing = listing
719
+ await this.loadMayUpdate()
468
720
  if (this.isPrimaryPresentation) {
469
721
  this.loading = false
470
722
  return
@@ -743,6 +995,15 @@ export default {
743
995
  color: var(--color-text-maxcontrast);
744
996
  }
745
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
+
746
1007
  .cn-object-parties__main-card {
747
1008
  display: flex;
748
1009
  flex-direction: column;
@@ -0,0 +1,309 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <CnDialog
8
+ :look="look"
9
+ :width="width"
10
+ defaultWidth="form"
11
+ :eyebrow="eyebrow"
12
+ :subtitle="subtitle"
13
+ :name="dialogTitle"
14
+ size="normal"
15
+ :noClose="loading"
16
+ @closing="$emit('close')">
17
+ <div class="cn-party-add" data-testid="cn-party-add-dialog">
18
+ <fieldset v-if="canCreate" class="cn-party-add__mode">
19
+ <legend class="cn-party-add__legend">
20
+ {{ t('nextcloud-vue', 'Who') }}
21
+ </legend>
22
+ <NcCheckboxRadioSwitch
23
+ v-model="mode"
24
+ type="radio"
25
+ :name="`${idPrefix}-mode`"
26
+ value="existing"
27
+ :disabled="loading"
28
+ data-testid="cn-party-add-mode-existing">
29
+ {{ t('nextcloud-vue', 'Someone already known') }}
30
+ </NcCheckboxRadioSwitch>
31
+ <NcCheckboxRadioSwitch
32
+ v-model="mode"
33
+ type="radio"
34
+ :name="`${idPrefix}-mode`"
35
+ value="new"
36
+ :disabled="loading"
37
+ data-testid="cn-party-add-mode-new">
38
+ {{ createLabel }}
39
+ </NcCheckboxRadioSwitch>
40
+ </fieldset>
41
+
42
+ <CnPartyPicker
43
+ v-if="mode === 'existing'"
44
+ v-model="partyUuid"
45
+ :sources="sources"
46
+ :inputLabel="t('nextcloud-vue', 'Person')"
47
+ labelOutside
48
+ :disabled="loading"
49
+ data-testid="cn-party-add-picker" />
50
+
51
+ <template v-else>
52
+ <NcTextField
53
+ v-for="field in createFields"
54
+ :key="field.key"
55
+ :modelValue="values[field.key] || ''"
56
+ :label="field.label"
57
+ :type="inputType(field)"
58
+ :required="field.required === true"
59
+ :disabled="loading"
60
+ :data-testid="`cn-party-add-field-${field.key}`"
61
+ @update:modelValue="setValue(field.key, $event)" />
62
+ </template>
63
+
64
+ <NcSelect
65
+ :modelValue="roleOption"
66
+ :options="roleOptions"
67
+ label="label"
68
+ :clearable="false"
69
+ :inputLabel="t('nextcloud-vue', 'Role')"
70
+ :disabled="loading"
71
+ data-testid="cn-party-add-role"
72
+ @update:modelValue="role = $event ? $event.key : ''" />
73
+
74
+ <NcCheckboxRadioSwitch
75
+ v-if="allowPrimary"
76
+ v-model="primary"
77
+ type="checkbox"
78
+ :disabled="loading"
79
+ data-testid="cn-party-add-primary">
80
+ {{ primaryLabel }}
81
+ </NcCheckboxRadioSwitch>
82
+
83
+ <NcNoteCard v-if="error" type="error" data-testid="cn-party-add-error">
84
+ {{ error }}
85
+ </NcNoteCard>
86
+ </div>
87
+
88
+ <template #actions>
89
+ <NcButton :disabled="loading" data-testid="cn-party-add-cancel" @click="$emit('close')">
90
+ {{ t('nextcloud-vue', 'Cancel') }}
91
+ </NcButton>
92
+ <NcButton
93
+ variant="primary"
94
+ :disabled="loading || !complete"
95
+ data-testid="cn-party-add-confirm"
96
+ @click="confirm">
97
+ <template #icon>
98
+ <NcLoadingIcon v-if="loading" :size="20" :name="t('nextcloud-vue', 'Loading …')" />
99
+ </template>
100
+ {{ confirmLabel }}
101
+ </NcButton>
102
+ </template>
103
+ </CnDialog>
104
+ </template>
105
+
106
+ <script>
107
+ import { translate as t } from '@nextcloud/l10n'
108
+ import { NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcNoteCard, NcSelect, NcTextField } from '@nextcloud/vue'
109
+ import CnDialog from '../components/CnDialog/CnDialog.vue'
110
+ import CnPartyPicker from '../components/CnPartyPicker/CnPartyPicker.vue'
111
+ import { dialogBoardMixin } from '../mixins/dialogBoard.js'
112
+
113
+ let uid = 0
114
+
115
+ /**
116
+ * CnPartyAddDialog — give a party a role on a record: pick somebody already
117
+ * known (CnPartyPicker over `sources`), or describe a new person with the
118
+ * `create.fields` the host declares (no identifier needed: a name and an
119
+ * address are enough). Then a role from `roles`, and optionally "make this the
120
+ * primary party".
121
+ *
122
+ * The dialog writes nothing. `confirm` emits `{ mode, partyUuid, values, role,
123
+ * primary }`; the host creates the person when `mode` is `new`, links the
124
+ * party, then closes the dialog or calls `fail(message)` to show the refusal
125
+ * (a 403 when the reader may not update the record) and let them try again.
126
+ *
127
+ * @spec openspec/changes/object-parties-manage/specs/record-widgets/spec.md#requirement-the-parties-widget-can-add-and-remove-parties
128
+ */
129
+ export default {
130
+ name: 'CnPartyAddDialog',
131
+
132
+ components: { CnDialog, CnPartyPicker, NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcNoteCard, NcSelect, NcTextField },
133
+
134
+ mixins: [dialogBoardMixin],
135
+
136
+ props: {
137
+ /** The dialog's title ("Add contact person"). */
138
+ dialogTitle: {
139
+ type: String,
140
+ default: () => t('nextcloud-vue', 'Add a party'),
141
+ },
142
+
143
+ /** The roles to choose from, `[{ key, label }]`, already in the reader's words. */
144
+ roles: {
145
+ type: Array,
146
+ default: () => [],
147
+ },
148
+
149
+ /** CnPartyPicker sources for somebody already known. */
150
+ sources: {
151
+ type: Array,
152
+ default: () => [],
153
+ },
154
+
155
+ /**
156
+ * The fields of a new person, `[{ key, label, type? (text, email, integer), required? }]`,
157
+ * labels already translated. None: only existing parties can be picked.
158
+ */
159
+ createFields: {
160
+ type: Array,
161
+ default: () => [],
162
+ },
163
+
164
+ /** The choice that opens the new-person fields. */
165
+ createLabel: {
166
+ type: String,
167
+ default: () => t('nextcloud-vue', 'Someone new'),
168
+ },
169
+
170
+ /** Offer "make this the primary party". */
171
+ allowPrimary: {
172
+ type: Boolean,
173
+ default: false,
174
+ },
175
+
176
+ /** The primary choice's words ("Main contact"). */
177
+ primaryLabel: {
178
+ type: String,
179
+ default: () => t('nextcloud-vue', 'Primary party'),
180
+ },
181
+
182
+ /** The confirm button's words. */
183
+ confirmLabel: {
184
+ type: String,
185
+ default: () => t('nextcloud-vue', 'Add'),
186
+ },
187
+ },
188
+
189
+ emits: ['close', 'confirm'],
190
+
191
+ data() {
192
+ uid += 1
193
+ return {
194
+ idPrefix: `cn-party-add-${uid}`,
195
+ mode: 'existing',
196
+ partyUuid: null,
197
+ values: {},
198
+ role: this.roles.length === 1 ? this.roles[0].key : '',
199
+ primary: false,
200
+ loading: false,
201
+ error: '',
202
+ }
203
+ },
204
+
205
+ computed: {
206
+ /** @return {boolean} Whether a new person can be described here. */
207
+ canCreate() {
208
+ return this.createFields.length > 0
209
+ },
210
+
211
+ /** @return {Array<{key: string, label: string}>} The role choices. */
212
+ roleOptions() {
213
+ return this.roles.filter((role) => role && role.key).map((role) => ({ key: role.key, label: role.label || role.key }))
214
+ },
215
+
216
+ /** @return {object|null} The chosen role's option. */
217
+ roleOption() {
218
+ return this.roleOptions.find((option) => option.key === this.role) || null
219
+ },
220
+
221
+ /** @return {boolean} Whether the choice is complete enough to send. */
222
+ complete() {
223
+ if (this.role === '') {
224
+ return false
225
+ }
226
+ if (this.mode === 'existing') {
227
+ return Boolean(this.partyUuid)
228
+ }
229
+ return this.createFields.every((field) => field.required !== true || String(this.values[field.key] || '').trim() !== '')
230
+ },
231
+ },
232
+
233
+ methods: {
234
+ t,
235
+
236
+ /**
237
+ * The input type of a new-person field.
238
+ *
239
+ * @param {{type?: string}} field The field.
240
+ * @return {string} `email`, `number` or `text`.
241
+ */
242
+ inputType(field) {
243
+ if (field.type === 'email') {
244
+ return 'email'
245
+ }
246
+ return field.type === 'integer' ? 'number' : 'text'
247
+ },
248
+
249
+ /**
250
+ * Set one new-person field.
251
+ *
252
+ * @param {string} key The field key.
253
+ * @param {string} value The value.
254
+ * @return {void}
255
+ */
256
+ setValue(key, value) {
257
+ this.values = { ...this.values, [key]: value }
258
+ },
259
+
260
+ /** @return {void} Hand the choice to the host. */
261
+ confirm() {
262
+ if (!this.complete) {
263
+ return
264
+ }
265
+ this.loading = true
266
+ this.error = ''
267
+ this.$emit('confirm', {
268
+ mode: this.mode,
269
+ partyUuid: this.mode === 'existing' ? String(this.partyUuid) : null,
270
+ values: this.mode === 'new' ? { ...this.values } : {},
271
+ role: this.role,
272
+ primary: this.allowPrimary && this.primary === true,
273
+ })
274
+ },
275
+
276
+ /**
277
+ * The host could not write: show why and let the reader try again.
278
+ *
279
+ * @param {string} message The refusal.
280
+ * @return {void}
281
+ */
282
+ fail(message) {
283
+ this.loading = false
284
+ this.error = message || t('nextcloud-vue', 'The party could not be added.')
285
+ },
286
+ },
287
+ }
288
+ </script>
289
+
290
+ <style scoped>
291
+ .cn-party-add {
292
+ display: flex;
293
+ flex-direction: column;
294
+ gap: calc(var(--default-grid-baseline, 4px) * 3);
295
+ }
296
+
297
+ .cn-party-add__mode {
298
+ display: flex;
299
+ flex-direction: column;
300
+ margin: 0;
301
+ padding: 0;
302
+ border: 0;
303
+ }
304
+
305
+ .cn-party-add__legend {
306
+ margin-bottom: var(--default-grid-baseline, 4px);
307
+ font-weight: 600;
308
+ }
309
+ </style>