@conduction/nextcloud-vue 2.70.0 → 2.72.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.
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +151 -13
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +3 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +38 -42
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue2.js +2 -0
- package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue2.js.map +1 -1
- package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue3.js +7 -0
- package/dist/esm/components/CnFlowDetail/CnRunDetailSidebar.vue3.js.map +1 -1
- package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue.js +10 -0
- package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue.js.map +1 -0
- package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue2.js +150 -0
- package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue2.js.map +1 -0
- package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue3.js +54 -0
- package/dist/esm/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue3.js.map +1 -0
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +29 -2
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +71 -34
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +22 -0
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue2.js +1 -1
- package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +46 -32
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +30 -9
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
- package/dist/esm/composables/useWalkthrough.js +11 -3
- package/dist/esm/composables/useWalkthrough.js.map +1 -1
- package/dist/esm/dialogs/CnFlowNodeEditModal.vue2.js +7 -10
- package/dist/esm/dialogs/CnFlowNodeEditModal.vue2.js.map +1 -1
- package/dist/esm/dialogs/CnFlowNodeEditModal.vue3.js +23 -13
- package/dist/esm/dialogs/CnFlowNodeEditModal.vue3.js.map +1 -1
- package/dist/esm/dialogs/CnRunNodeDialog.vue2.js +8 -6
- package/dist/esm/dialogs/CnRunNodeDialog.vue2.js.map +1 -1
- package/dist/esm/dialogs/CnRunNodeDialog.vue3.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +15 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +15 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +117 -0
- package/dist/esm/utils/columnFilters.js +54 -4
- package/dist/esm/utils/columnFilters.js.map +1 -1
- package/dist/esm/utils/selectOptions.js +57 -0
- package/dist/esm/utils/selectOptions.js.map +1 -0
- package/dist/esm/utils/userAutocomplete.js +69 -3
- package/dist/esm/utils/userAutocomplete.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +6560 -5880
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +117 -0
- package/l10n/en.json +15 -1
- package/l10n/nl.json +15 -1
- package/package.json +1 -1
- package/src/components/CnAppRoot/CnAppRoot.vue +153 -13
- package/src/components/CnDashboardPage/CnDashboardPage.vue +20 -18
- package/src/components/CnFlowDetail/CnRunDetailSidebar.vue +6 -0
- package/src/components/CnFlowStepOutcomes/CnFlowStepOutcomes.vue +250 -0
- package/src/components/CnFlowStepOutcomes/index.js +8 -0
- package/src/components/CnFormDialog/CnFormDialog.vue +62 -2
- package/src/components/CnIndexPage/CnIndexPage.vue +23 -1
- package/src/components/CnObjectSidebar/CnNotesTab.vue +1 -1
- package/src/components/CnSetupWizard/CnSetupWizard.vue +7 -0
- package/src/components/CnWalkthrough/CnWalkthrough.vue +30 -9
- package/src/components/index.js +1 -0
- package/src/composables/useWalkthrough.js +11 -3
- package/src/css/dashboard.css +44 -0
- package/src/dialogs/CnFlowNodeEditModal.vue +41 -20
- package/src/dialogs/CnRunNodeDialog.vue +8 -6
- package/src/index.js +1 -0
- package/src/schemas/app-manifest-v2.schema.json +2 -1
- package/src/utils/columnFilters.js +54 -4
- package/src/utils/selectOptions.js +54 -0
- package/src/utils/userAutocomplete.js +68 -2
- package/src/utils/validateManifestV2.compiled.js +2 -2
|
@@ -40,16 +40,27 @@
|
|
|
40
40
|
:placeholder="t('nextcloud-vue', 'GET')"
|
|
41
41
|
@update:modelValue="setKey(key, $event)" />
|
|
42
42
|
|
|
43
|
-
<!-- A `select` field
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
<!-- A `select` field renders as a picker, never a bare text box.
|
|
44
|
+
Its choices come from the URL the OWNING APP declared
|
|
45
|
+
(`optionsFrom`) or from the form itself (`options`), for a
|
|
46
|
+
short fixed vocabulary with no endpoint of its own. -->
|
|
47
|
+
<template v-else-if="widgetFor(key) === 'select'">
|
|
48
|
+
<NcSelect :modelValue="selectedOption(key)"
|
|
49
|
+
class="cn-flow-node-edit__select"
|
|
50
|
+
:options="selectOptions[key] || []"
|
|
51
|
+
:inputLabel="labelFor(key)"
|
|
52
|
+
:loading="selectLoading[key] === true"
|
|
53
|
+
:placeholder="t('nextcloud-vue', 'Pick one…')"
|
|
54
|
+
@update:modelValue="setKey(key, $event ? $event.id : '')" />
|
|
55
|
+
<!-- NcSelect has no helper text of its own, so the help
|
|
56
|
+
follows the control, the next thing a screen reader
|
|
57
|
+
reads after it. -->
|
|
58
|
+
<p v-if="hintFor(key)"
|
|
59
|
+
class="cn-flow-node-edit__field-help"
|
|
60
|
+
:data-testid="`flow-node-help-${key}`">
|
|
61
|
+
{{ hintFor(key) }}
|
|
62
|
+
</p>
|
|
63
|
+
</template>
|
|
53
64
|
|
|
54
65
|
<NcTextArea v-else-if="widgetFor(key) === 'textarea'"
|
|
55
66
|
:modelValue="String(draft.config[key] ?? '')"
|
|
@@ -170,6 +181,7 @@ import {
|
|
|
170
181
|
} from '@nextcloud/vue'
|
|
171
182
|
import CnCronField from '../components/CnCronField/CnCronField.vue'
|
|
172
183
|
import { useFlowStore } from '../composables/useFlowStore.js'
|
|
184
|
+
import { hasStaticOptions, normaliseSelectOptions } from '../utils/selectOptions.js'
|
|
173
185
|
|
|
174
186
|
/** The verbs an HTTP-shaped `method` option can take. */
|
|
175
187
|
/**
|
|
@@ -287,7 +299,7 @@ export default {
|
|
|
287
299
|
|
|
288
300
|
/**
|
|
289
301
|
* The node's per-field declarations from the catalogue's `configForm`
|
|
290
|
-
* ({key, label, type, help, required, optionsFrom}), keyed by config
|
|
302
|
+
* ({key, label, type, help, required, optionsFrom, options}), keyed by config
|
|
291
303
|
* key. `configKeys` is the degraded form. Either way the node's OWNER
|
|
292
304
|
* declares the vocabulary — this dialog never invents fields.
|
|
293
305
|
*
|
|
@@ -398,7 +410,9 @@ export default {
|
|
|
398
410
|
created() {
|
|
399
411
|
// Select fields need their options; everything else is local.
|
|
400
412
|
for (const [key, spec] of Object.entries(this.fieldSpecs)) {
|
|
401
|
-
if (spec
|
|
413
|
+
if (hasStaticOptions(spec)) {
|
|
414
|
+
this.selectOptions = { ...this.selectOptions, [key]: normaliseSelectOptions(spec.options) }
|
|
415
|
+
} else if (spec?.type === 'select' && spec?.optionsFrom) {
|
|
402
416
|
this.loadSelectOptions(key, spec)
|
|
403
417
|
}
|
|
404
418
|
}
|
|
@@ -634,7 +648,7 @@ export default {
|
|
|
634
648
|
*/
|
|
635
649
|
widgetFor(key) {
|
|
636
650
|
const spec = this.fieldSpecs[key]
|
|
637
|
-
if (spec?.type === 'select' && spec?.optionsFrom) {
|
|
651
|
+
if (spec?.type === 'select' && (spec?.optionsFrom || hasStaticOptions(spec))) {
|
|
638
652
|
return 'select'
|
|
639
653
|
}
|
|
640
654
|
if (spec?.type === 'boolean') {
|
|
@@ -730,13 +744,7 @@ export default {
|
|
|
730
744
|
? generateUrl(url)
|
|
731
745
|
: url)
|
|
732
746
|
const rows = Array.isArray(response.data) ? response.data : (response.data?.results || [])
|
|
733
|
-
const options = rows
|
|
734
|
-
const id = row.id ?? row.value ?? row['@self']?.uuid ?? row.uuid
|
|
735
|
-
return {
|
|
736
|
-
id,
|
|
737
|
-
label: row.label || row.name || row.title || String(id),
|
|
738
|
-
}
|
|
739
|
-
}).filter((o) => o.id !== undefined && o.id !== null && o.id !== '')
|
|
747
|
+
const options = normaliseSelectOptions(rows)
|
|
740
748
|
this.selectOptions = { ...this.selectOptions, [key]: options }
|
|
741
749
|
} catch (error) {
|
|
742
750
|
// A picker that could not load degrades to showing the stored
|
|
@@ -930,6 +938,19 @@ export default {
|
|
|
930
938
|
color: var(--color-text-maxcontrast);
|
|
931
939
|
}
|
|
932
940
|
|
|
941
|
+
/* Full width, like the text fields around it: at its natural width the
|
|
942
|
+
picker cut every option label in half. */
|
|
943
|
+
.cn-flow-node-edit__select {
|
|
944
|
+
width: 100%;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
.cn-flow-node-edit__field-help {
|
|
948
|
+
margin: 4px 0 0;
|
|
949
|
+
padding-inline-start: 8px;
|
|
950
|
+
font-size: 0.85em;
|
|
951
|
+
color: var(--color-text-maxcontrast);
|
|
952
|
+
}
|
|
953
|
+
|
|
933
954
|
.cn-flow-node-edit__advanced summary {
|
|
934
955
|
cursor: pointer;
|
|
935
956
|
color: var(--color-text-maxcontrast);
|
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
import { translate as t } from '@nextcloud/l10n'
|
|
89
89
|
import { NcButton, NcCheckboxRadioSwitch, NcDialog, NcLoadingIcon, NcNoteCard, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue'
|
|
90
90
|
import { prefixUrl } from '../utils/headers.js'
|
|
91
|
+
import { hasStaticOptions, normaliseSelectOptions } from '../utils/selectOptions.js'
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* The dialog `run-node` opens to collect a directly-invoked flow node's own
|
|
@@ -128,7 +129,9 @@ export default {
|
|
|
128
129
|
title: { type: String, default: '' },
|
|
129
130
|
/**
|
|
130
131
|
* The node's declared config form — `IFlowNodeConfigForm::configForm()`'s
|
|
131
|
-
* shape, unchanged: `[{key, label, type, help?, required?, optionsFrom?}]`.
|
|
132
|
+
* shape, unchanged: `[{key, label, type, help?, required?, optionsFrom?, options?}]`.
|
|
133
|
+
* A `select` takes its choices from `optionsFrom` (a url) or `options`
|
|
134
|
+
* (strings, or `{value, label}` pairs declared in the form itself).
|
|
132
135
|
*
|
|
133
136
|
* @type {Array<object>}
|
|
134
137
|
*/
|
|
@@ -177,7 +180,9 @@ export default {
|
|
|
177
180
|
}
|
|
178
181
|
this.values = seed
|
|
179
182
|
for (const field of (fields || [])) {
|
|
180
|
-
if (field
|
|
183
|
+
if (hasStaticOptions(field)) {
|
|
184
|
+
this.options = { ...this.options, [field.key]: normaliseSelectOptions(field.options, (s) => this.tr(s)) }
|
|
185
|
+
} else if (field.type === 'select' && field.optionsFrom) {
|
|
181
186
|
this.loadOptions(field)
|
|
182
187
|
}
|
|
183
188
|
}
|
|
@@ -274,10 +279,7 @@ export default {
|
|
|
274
279
|
const url = prefixUrl(raw)
|
|
275
280
|
const response = await axios.get(url)
|
|
276
281
|
const rows = Array.isArray(response.data) ? response.data : (response.data?.results || [])
|
|
277
|
-
const opts = rows
|
|
278
|
-
const id = row.id ?? row.value ?? row['@self']?.uuid ?? row.uuid
|
|
279
|
-
return { id, label: row.label || row.name || row.title || String(id) }
|
|
280
|
-
}).filter((o) => o.id !== undefined && o.id !== null && o.id !== '')
|
|
282
|
+
const opts = normaliseSelectOptions(rows)
|
|
281
283
|
this.options = { ...this.options, [field.key]: opts }
|
|
282
284
|
} catch (error) {
|
|
283
285
|
// A picker that fails to load degrades to an empty list rather
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
|
|
4
4
|
"title": "Conduction App Manifest v2",
|
|
5
5
|
"description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.53.0",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"required": ["$schema", "version"],
|
|
9
9
|
"allOf": [
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"enabled": { "type": "boolean", "default": true, "description": "Whether the setup wizard is active for this app." },
|
|
65
65
|
"version": { "type": "integer", "default": 1, "description": "Setup contract version; bump to re-trigger setup after a breaking change." },
|
|
66
66
|
"completionConfigKey": { "type": "string", "description": "App-config key storing the completed setup version." },
|
|
67
|
+
"dismissAction": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$", "description": "Setup action that records a closed or finished setup wizard on the server, so it stays closed in every browser. CnAppRoot posts `/apps/{appId}/api/setup/action/{dismissAction}` with `{ finished }` once when the user closes or finishes the wizard. The app either answers the outstanding optional steps or reports `dismissed` (true, or the setup version it was closed at) in `GET /api/setup/status`. Without it, the close is remembered only in the browser (localStorage), as before." },
|
|
67
68
|
"steps": {
|
|
68
69
|
"type": "array",
|
|
69
70
|
"description": "Ordered setup steps rendered by CnSetupWizard.",
|
|
@@ -161,15 +161,65 @@ function referenceTarget(column, prop) {
|
|
|
161
161
|
return { schema, register, labelField }
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Whether a schema lists its properties. Without that the table knows its
|
|
166
|
+
* columns but not what backs them.
|
|
167
|
+
*
|
|
168
|
+
* @param {object|null} schema The table's schema.
|
|
169
|
+
* @return {boolean} True when the schema has a properties map.
|
|
170
|
+
*/
|
|
171
|
+
function hasProperties(schema) {
|
|
172
|
+
return !!(schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A filter for a column when the table has no schema to read (a manifest
|
|
177
|
+
* page whose schema did not resolve, a host-fed table). The column's own
|
|
178
|
+
* hints decide the kind: an `fkResolve` widget gives a reference filter, a
|
|
179
|
+
* `type` or `format` on the column gives number, date or yes/no, and every
|
|
180
|
+
* other plain stored key gets a text filter on contains (`[like]`). A key
|
|
181
|
+
* that is a path (`a.b`) or a metadata field (`@self`, `_id`) gets none,
|
|
182
|
+
* because OpenRegister does not filter those by that name.
|
|
183
|
+
*
|
|
184
|
+
* @spec openspec/changes/audit-round-lib-fixes/specs/cn-data-table/spec.md
|
|
185
|
+
* @param {object} column The column definition.
|
|
186
|
+
* @param {object} base The shared part of the definition.
|
|
187
|
+
* @return {object|null} The filter definition.
|
|
188
|
+
*/
|
|
189
|
+
function columnHintFilterDef(column, base) {
|
|
190
|
+
const key = column.key
|
|
191
|
+
if (key.includes('.') || key.charAt(0) === '@' || key.charAt(0) === '_') {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
const reference = referenceTarget(column, null)
|
|
195
|
+
if (reference) {
|
|
196
|
+
return { ...base, kind: 'reference', reference }
|
|
197
|
+
}
|
|
198
|
+
const type = String(column.type || '').toLowerCase()
|
|
199
|
+
const format = String(column.format || '').toLowerCase()
|
|
200
|
+
if (type === 'boolean') {
|
|
201
|
+
return { ...base, kind: 'boolean' }
|
|
202
|
+
}
|
|
203
|
+
if (type === 'number' || type === 'integer') {
|
|
204
|
+
return { ...base, kind: 'number' }
|
|
205
|
+
}
|
|
206
|
+
if (type === 'date' || type === 'date-time' || format === 'date' || format === 'date-time') {
|
|
207
|
+
return { ...base, kind: 'date', dateTime: type === 'date-time' || format === 'date-time' }
|
|
208
|
+
}
|
|
209
|
+
return { ...base, kind: 'string' }
|
|
210
|
+
}
|
|
211
|
+
|
|
164
212
|
/**
|
|
165
213
|
* How a column filters, or null when it does not.
|
|
166
214
|
*
|
|
167
215
|
* Kinds: `enum` (checkbox list), `boolean` (yes, no, any), `reference`
|
|
168
216
|
* (searchable list of the referenced objects), `number` and `date` (from and
|
|
169
217
|
* to), and `string` (contains, through OpenRegister's `[like]`). `filterable: false` on the column turns it
|
|
170
|
-
* off.
|
|
171
|
-
* carries an enum hint (`enum`, or a badge
|
|
172
|
-
* server cannot filter on a value it does
|
|
218
|
+
* off. When the schema lists its properties, a column with no property
|
|
219
|
+
* behind it only filters when it carries an enum hint (`enum`, or a badge
|
|
220
|
+
* widget's colour map), because the server cannot filter on a value it does
|
|
221
|
+
* not store. When there is no schema to read, the column's own hints decide
|
|
222
|
+
* (see `columnHintFilterDef`), falling back to a text filter on contains.
|
|
173
223
|
*
|
|
174
224
|
* @param {object} column The column definition.
|
|
175
225
|
* @param {object|null} schema The table's schema.
|
|
@@ -191,7 +241,7 @@ export function columnFilterDef(column, schema) {
|
|
|
191
241
|
return { ...base, kind: 'enum', options }
|
|
192
242
|
}
|
|
193
243
|
if (!prop) {
|
|
194
|
-
return null
|
|
244
|
+
return hasProperties(schema) ? null : columnHintFilterDef(column, base)
|
|
195
245
|
}
|
|
196
246
|
const reference = referenceTarget(column, prop)
|
|
197
247
|
if (reference) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* The choices a node's `select` field offers, in the `{id, label}` shape
|
|
6
|
+
* NcSelect takes.
|
|
7
|
+
*
|
|
8
|
+
* A field gets its choices one of two ways. `optionsFrom` names a url the
|
|
9
|
+
* owning app answers. `options` lists them in the form itself, for a short
|
|
10
|
+
* fixed vocabulary that has no endpoint of its own. Both dialogs that render a
|
|
11
|
+
* node's configForm read the same shapes through this module, so the two
|
|
12
|
+
* cannot come to accept different ones.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalise rows into `{id, label}` options.
|
|
17
|
+
*
|
|
18
|
+
* Accepts plain strings and numbers, `{id, label}`, `{value, label}`, and
|
|
19
|
+
* OpenRegister objects (uuid from `@self`, label from name or title). A row
|
|
20
|
+
* without an id is dropped rather than shown as a blank option.
|
|
21
|
+
*
|
|
22
|
+
* @param {Array<unknown>} rows The raw rows.
|
|
23
|
+
* @param {(label: string) => string} [translate] Applied to each declared label.
|
|
24
|
+
* @return {Array<{id: string|number, label: string}>} The options.
|
|
25
|
+
*/
|
|
26
|
+
export function normaliseSelectOptions(rows, translate = (s) => s) {
|
|
27
|
+
if (!Array.isArray(rows)) {
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return rows.map((row) => {
|
|
32
|
+
if (typeof row === 'string' || typeof row === 'number') {
|
|
33
|
+
return { id: row, label: String(row) }
|
|
34
|
+
}
|
|
35
|
+
if (!row || typeof row !== 'object') {
|
|
36
|
+
return { id: undefined, label: '' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const id = row.id ?? row.value ?? row['@self']?.uuid ?? row.uuid
|
|
40
|
+
const declared = row.label || row.name || row.title
|
|
41
|
+
|
|
42
|
+
return { id, label: declared ? String(translate(declared)) : String(id) }
|
|
43
|
+
}).filter((option) => option.id !== undefined && option.id !== null && option.id !== '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a field declares its choices in the form itself.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} field The field declaration.
|
|
50
|
+
* @return {boolean} True for a `select` with an `options` list.
|
|
51
|
+
*/
|
|
52
|
+
export function hasStaticOptions(field) {
|
|
53
|
+
return field?.type === 'select' && Array.isArray(field?.options)
|
|
54
|
+
}
|
|
@@ -12,9 +12,50 @@
|
|
|
12
12
|
* @module utils/userAutocomplete
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { getCurrentUser } from '@nextcloud/auth'
|
|
15
16
|
import axios from '@nextcloud/axios'
|
|
16
17
|
import { generateOcsUrl } from '@nextcloud/router'
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The signed-in user as a picker option, or null when nobody is signed in.
|
|
21
|
+
*
|
|
22
|
+
* Core autocomplete never returns the person who searches (it is built for
|
|
23
|
+
* sharing, and you do not share with yourself), so a picker backed by it
|
|
24
|
+
* alone cannot assign a task to yourself.
|
|
25
|
+
*
|
|
26
|
+
* @spec openspec/changes/audit-round-lib-fixes/specs/schema-utilities/spec.md
|
|
27
|
+
* @return {{id: string, label: string, displayName: string, subline: string}|null} The option.
|
|
28
|
+
*/
|
|
29
|
+
function currentUserOption() {
|
|
30
|
+
let user
|
|
31
|
+
try {
|
|
32
|
+
user = getCurrentUser()
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
if (!user || !user.uid) {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
const name = user.displayName || String(user.uid)
|
|
40
|
+
return { id: String(user.uid), label: name, displayName: name, subline: '' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether the current-user option matches a search term (uid or display
|
|
45
|
+
* name contains the term, ignoring case). An empty term matches.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} option The current-user option.
|
|
48
|
+
* @param {string} query The search term.
|
|
49
|
+
* @return {boolean} True when it matches.
|
|
50
|
+
*/
|
|
51
|
+
function matchesQuery(option, query) {
|
|
52
|
+
const q = String(query || '').trim().toLowerCase()
|
|
53
|
+
if (q === '') {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
return option.id.toLowerCase().includes(q) || option.label.toLowerCase().includes(q)
|
|
57
|
+
}
|
|
58
|
+
|
|
18
59
|
/**
|
|
19
60
|
* Map one autocomplete suggestion to a picker option, keeping only user
|
|
20
61
|
* results. The OCS payload uses `id` for the UID, `label` for the display
|
|
@@ -59,10 +100,11 @@ function toUserOption(suggestion) {
|
|
|
59
100
|
* @param {string} [query] The search term (empty loads an initial page).
|
|
60
101
|
* @param {object} [options] Tuning options.
|
|
61
102
|
* @param {number} [options.limit] Max results (default 25).
|
|
103
|
+
* @param {boolean} [options.includeCurrentUser] Add the signed-in user when they match (default true; a mention list passes false).
|
|
62
104
|
* @return {Promise<Array<{id: string, label: string, subline: string}>>} Picker options.
|
|
63
105
|
*/
|
|
64
106
|
export async function searchNextcloudUsers(query = '', options = {}) {
|
|
65
|
-
const { limit = 25 } = options
|
|
107
|
+
const { limit = 25, includeCurrentUser = true } = options
|
|
66
108
|
try {
|
|
67
109
|
const url = generateOcsUrl('core/autocomplete/get')
|
|
68
110
|
const response = await axios.get(url, {
|
|
@@ -86,14 +128,32 @@ export async function searchNextcloudUsers(query = '', options = {}) {
|
|
|
86
128
|
const list = Array.isArray(data)
|
|
87
129
|
? data
|
|
88
130
|
: (Array.isArray(response && response.data) ? response.data : [])
|
|
89
|
-
|
|
131
|
+
const found = list
|
|
90
132
|
.map(toUserOption)
|
|
91
133
|
.filter((opt) => opt !== null)
|
|
134
|
+
return includeCurrentUser ? withCurrentUser(found, query) : found
|
|
92
135
|
} catch {
|
|
93
136
|
return []
|
|
94
137
|
}
|
|
95
138
|
}
|
|
96
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Put the signed-in user first in a result list when they match the search
|
|
142
|
+
* and the server left them out.
|
|
143
|
+
*
|
|
144
|
+
* @spec openspec/changes/audit-round-lib-fixes/specs/schema-utilities/spec.md
|
|
145
|
+
* @param {Array<object>} options The server's options.
|
|
146
|
+
* @param {string} query The search term.
|
|
147
|
+
* @return {Array<object>} The options, the current user included when they match.
|
|
148
|
+
*/
|
|
149
|
+
function withCurrentUser(options, query) {
|
|
150
|
+
const me = currentUserOption()
|
|
151
|
+
if (!me || !matchesQuery(me, query) || options.some((opt) => opt.id === me.id)) {
|
|
152
|
+
return options
|
|
153
|
+
}
|
|
154
|
+
return [me, ...options]
|
|
155
|
+
}
|
|
156
|
+
|
|
97
157
|
/**
|
|
98
158
|
* Resolve a single user UID to a `{ id, label }` option for edit-mode display.
|
|
99
159
|
* Tries the autocomplete endpoint with the UID as the query and matches the
|
|
@@ -108,6 +168,12 @@ export async function resolveNextcloudUser(uid) {
|
|
|
108
168
|
if (uid === undefined || uid === null || uid === '') {
|
|
109
169
|
return fallback
|
|
110
170
|
}
|
|
171
|
+
// The server never returns the signed-in user, so their own uid would
|
|
172
|
+
// otherwise fall back to the bare uid.
|
|
173
|
+
const me = currentUserOption()
|
|
174
|
+
if (me && me.id === String(uid)) {
|
|
175
|
+
return me
|
|
176
|
+
}
|
|
111
177
|
const results = await searchNextcloudUsers(String(uid))
|
|
112
178
|
const match = results.find((opt) => opt.id === String(uid))
|
|
113
179
|
return match || fallback
|