@conduction/nextcloud-vue 1.0.0-beta.20 → 1.0.0-beta.22
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/nextcloud-vue.cjs.js +831 -110
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +59 -7
- package/dist/nextcloud-vue.esm.js +830 -111
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/CnSettingsPage/CnSettingsPage.vue +305 -26
- package/src/composables/useAppManifest.js +37 -9
- package/src/index.js +1 -0
- package/src/schemas/app-manifest.schema.json +40 -2
- package/src/utils/resolveManifestSentinels.js +268 -0
- package/src/utils/validateManifest.js +216 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/nextcloud-vue",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.22",
|
|
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
|
"license": "EUPL-1.2",
|
|
6
6
|
"author": "Conduction B.V. <info@conduction.nl>",
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
CnSettingsPage — Admin / config surface.
|
|
3
3
|
|
|
4
|
-
Renders manifest-driven config sections.
|
|
5
|
-
`
|
|
4
|
+
Renders manifest-driven config sections. The page MAY declare
|
|
5
|
+
EITHER a flat `sections[]` array (back-compat) OR a `tabs[]` array
|
|
6
|
+
(each tab owns its own `sections[]`) — XOR, never both. See
|
|
7
|
+
manifest-settings-orchestration spec.
|
|
8
|
+
|
|
9
|
+
Each section is a CnSettingsCard wrapping a CnSettingsSection.
|
|
6
10
|
A section MUST declare exactly one of three body kinds:
|
|
7
11
|
|
|
8
12
|
1. `fields[]` — flat form fields (back-compat, default)
|
|
@@ -12,6 +16,8 @@
|
|
|
12
16
|
Built-in widget types (resolved BEFORE the customComponents registry):
|
|
13
17
|
- `version-info` → CnVersionInfoCard
|
|
14
18
|
- `register-mapping` → CnRegisterMapping
|
|
19
|
+
- `component` → discriminator; `widget.componentName`
|
|
20
|
+
resolves against customComponents
|
|
15
21
|
|
|
16
22
|
Saves via `axios.put(saveEndpoint, formData)` with the consumer's
|
|
17
23
|
settings controller URL. Widget events bubble up through
|
|
@@ -44,10 +50,33 @@
|
|
|
44
50
|
<slot name="actions" />
|
|
45
51
|
</div>
|
|
46
52
|
|
|
47
|
-
<!--
|
|
53
|
+
<!-- Tab strip — only rendered when `tabs[]` is set
|
|
54
|
+
(manifest-settings-orchestration REQ-MSO-5). Built with
|
|
55
|
+
native <button role="tab"> + ARIA wiring; uses Nextcloud
|
|
56
|
+
CSS variables only (no hex literals). -->
|
|
57
|
+
<div
|
|
58
|
+
v-if="hasTabs"
|
|
59
|
+
class="cn-settings-page__tabs"
|
|
60
|
+
role="tablist">
|
|
61
|
+
<button
|
|
62
|
+
v-for="(tab, tabIndex) in tabs"
|
|
63
|
+
:key="`tab-${tab.id}`"
|
|
64
|
+
role="tab"
|
|
65
|
+
type="button"
|
|
66
|
+
class="cn-settings-page__tab"
|
|
67
|
+
:class="{ 'cn-settings-page__tab--active': activeTabId === tab.id }"
|
|
68
|
+
:aria-selected="activeTabId === tab.id ? 'true' : 'false'"
|
|
69
|
+
:aria-controls="`cn-settings-tab-panel-${tab.id}`"
|
|
70
|
+
@click="onTabClick(tab, tabIndex)">
|
|
71
|
+
{{ resolveLabel(tab.label) }}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<!-- Sections — rendered for the active tab when `tabs[]` is
|
|
76
|
+
set, otherwise the flat `sections[]` (back-compat). -->
|
|
48
77
|
<CnSettingsCard
|
|
49
|
-
v-for="(section, sectionIndex) in
|
|
50
|
-
:key="`section-${sectionIndex}`"
|
|
78
|
+
v-for="(section, sectionIndex) in activeSections"
|
|
79
|
+
:key="`section-${activeTabId || 'flat'}-${sectionIndex}`"
|
|
51
80
|
:title="resolveLabel(section.title)"
|
|
52
81
|
:icon="section.icon || ''"
|
|
53
82
|
:collapsible="section.collapsible || false">
|
|
@@ -198,6 +227,15 @@ import CnVersionInfoCard from '../CnVersionInfoCard/CnVersionInfoCard.vue'
|
|
|
198
227
|
import CnRegisterMapping from '../CnRegisterMapping/CnRegisterMapping.vue'
|
|
199
228
|
import CnSettingsWidgetMount from './CnSettingsWidgetMount.js'
|
|
200
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Sentinel value used in the built-in widget registry to mark the
|
|
232
|
+
* `'component'` discriminator (manifest-settings-orchestration
|
|
233
|
+
* REQ-MSO-6). The discriminator does NOT resolve to a fixed
|
|
234
|
+
* component — instead, the resolver detects this sentinel and looks
|
|
235
|
+
* up `widget.componentName` in the customComponents registry.
|
|
236
|
+
*/
|
|
237
|
+
const COMPONENT_DISCRIMINATOR = Symbol('cn-settings-component-widget')
|
|
238
|
+
|
|
201
239
|
/**
|
|
202
240
|
* Built-in widget registry. Used by `CnSettingsPage` to resolve
|
|
203
241
|
* `widgets[].type` to a component BEFORE consulting the
|
|
@@ -206,13 +244,19 @@ import CnSettingsWidgetMount from './CnSettingsWidgetMount.js'
|
|
|
206
244
|
* The order matters — built-ins win on collision so consumers can't
|
|
207
245
|
* accidentally shadow `version-info` with their own component. If a
|
|
208
246
|
* consumer needs to truly replace one of these, they can render their
|
|
209
|
-
* own component via `section.component`
|
|
247
|
+
* own component via `section.component` or
|
|
248
|
+
* `{ type: "component", componentName: <name> }` instead of `widgets[]`.
|
|
210
249
|
*
|
|
211
|
-
* Spec:
|
|
250
|
+
* Spec:
|
|
251
|
+
* - REQ-MSRS-2 (manifest-settings-rich-sections) — fixed-component
|
|
252
|
+
* built-ins (`version-info`, `register-mapping`).
|
|
253
|
+
* - REQ-MSO-6 (manifest-settings-orchestration) — `'component'`
|
|
254
|
+
* discriminator (sentinel value, resolved via componentName).
|
|
212
255
|
*/
|
|
213
256
|
const BUILTIN_SETTINGS_WIDGETS = Object.freeze({
|
|
214
257
|
'version-info': CnVersionInfoCard,
|
|
215
258
|
'register-mapping': CnRegisterMapping,
|
|
259
|
+
component: COMPONENT_DISCRIMINATOR,
|
|
216
260
|
})
|
|
217
261
|
|
|
218
262
|
/**
|
|
@@ -320,19 +364,50 @@ export default {
|
|
|
320
364
|
default: '',
|
|
321
365
|
},
|
|
322
366
|
/**
|
|
323
|
-
* Section definitions
|
|
367
|
+
* Section definitions (flat shape — back-compat). Each section
|
|
368
|
+
* MUST declare EXACTLY ONE of:
|
|
324
369
|
* - `fields: Array<Field>` (back-compat flat-field body)
|
|
325
370
|
* - `component: <registry-name>` + optional `props`
|
|
326
|
-
* - `widgets: Array<{ type, props? }>`
|
|
371
|
+
* - `widgets: Array<{ type, props?, componentName? }>`
|
|
327
372
|
*
|
|
328
373
|
* Common keys: `{ title, description?, icon?, collapsible?, docUrl? }`.
|
|
329
374
|
*
|
|
375
|
+
* Mutually exclusive with `tabs[]` (XOR — see
|
|
376
|
+
* manifest-settings-orchestration REQ-MSO-1).
|
|
377
|
+
*
|
|
330
378
|
* @type {Array<object>}
|
|
331
379
|
*/
|
|
332
380
|
sections: {
|
|
333
381
|
type: Array,
|
|
334
382
|
default: () => [],
|
|
335
383
|
},
|
|
384
|
+
/**
|
|
385
|
+
* Tab definitions (orchestration shape — manifest-settings-
|
|
386
|
+
* orchestration REQ-MSO-2). When set, CnSettingsPage renders
|
|
387
|
+
* a tab strip above the section area; the active tab's
|
|
388
|
+
* `sections[]` flow into the same renderer used by the flat
|
|
389
|
+
* shape. Mutually exclusive with `sections[]`.
|
|
390
|
+
*
|
|
391
|
+
* Each tab MUST be `{ id: string, label: string,
|
|
392
|
+
* icon?: string, sections: array<Section> }`.
|
|
393
|
+
*
|
|
394
|
+
* @type {Array<object>}
|
|
395
|
+
*/
|
|
396
|
+
tabs: {
|
|
397
|
+
type: Array,
|
|
398
|
+
default: () => [],
|
|
399
|
+
},
|
|
400
|
+
/**
|
|
401
|
+
* Optional ID of the tab to activate on mount. When empty AND
|
|
402
|
+
* `tabs[]` is non-empty, the first tab is active by default.
|
|
403
|
+
* Unknown IDs fall back to the first tab.
|
|
404
|
+
*
|
|
405
|
+
* @type {string}
|
|
406
|
+
*/
|
|
407
|
+
initialTab: {
|
|
408
|
+
type: String,
|
|
409
|
+
default: '',
|
|
410
|
+
},
|
|
336
411
|
/**
|
|
337
412
|
* Initial values keyed by `field.key`. Defaults to an empty
|
|
338
413
|
* object; in practice the consumer passes the current
|
|
@@ -394,14 +469,30 @@ export default {
|
|
|
394
469
|
},
|
|
395
470
|
},
|
|
396
471
|
|
|
397
|
-
emits: ['save', 'error', 'input', 'widget-event'],
|
|
472
|
+
emits: ['save', 'error', 'input', 'widget-event', 'tab-change'],
|
|
398
473
|
|
|
399
474
|
data() {
|
|
475
|
+
// Resolve the initial active-tab id synchronously so the very
|
|
476
|
+
// first render has the correct tab active (otherwise tests
|
|
477
|
+
// that mount + assert without an `await tick` see the empty
|
|
478
|
+
// default). Mirrors the logic in `resolveInitialTabId` (the
|
|
479
|
+
// watcher path); keep them aligned.
|
|
480
|
+
let activeTabId = ''
|
|
481
|
+
const tabs = Array.isArray(this.tabs) ? this.tabs : []
|
|
482
|
+
if (tabs.length > 0) {
|
|
483
|
+
if (typeof this.initialTab === 'string' && this.initialTab.length > 0
|
|
484
|
+
&& tabs.some(t => t && t.id === this.initialTab)) {
|
|
485
|
+
activeTabId = this.initialTab
|
|
486
|
+
} else if (tabs[0] && typeof tabs[0].id === 'string') {
|
|
487
|
+
activeTabId = tabs[0].id
|
|
488
|
+
}
|
|
489
|
+
}
|
|
400
490
|
return {
|
|
401
491
|
formData: this.cloneInitial(),
|
|
402
492
|
originalData: this.cloneInitial(),
|
|
403
493
|
saving: false,
|
|
404
494
|
lastError: null,
|
|
495
|
+
activeTabId,
|
|
405
496
|
}
|
|
406
497
|
},
|
|
407
498
|
|
|
@@ -420,6 +511,35 @@ export default {
|
|
|
420
511
|
effectiveCustomComponents() {
|
|
421
512
|
return this.customComponents ?? this.cnCustomComponents ?? {}
|
|
422
513
|
},
|
|
514
|
+
/**
|
|
515
|
+
* Whether the page is in tabs orchestration mode. True when
|
|
516
|
+
* `tabs[]` is non-empty — drives the tab-strip render gate
|
|
517
|
+
* (manifest-settings-orchestration REQ-MSO-5).
|
|
518
|
+
*
|
|
519
|
+
* @return {boolean}
|
|
520
|
+
*/
|
|
521
|
+
hasTabs() {
|
|
522
|
+
return Array.isArray(this.tabs) && this.tabs.length > 0
|
|
523
|
+
},
|
|
524
|
+
/**
|
|
525
|
+
* The sections to render right now. In flat mode, this is the
|
|
526
|
+
* `sections` prop directly. In tabs mode, this is the
|
|
527
|
+
* `sections[]` array of the currently active tab. Centralising
|
|
528
|
+
* this in one computed keeps the template's `v-for` simple
|
|
529
|
+
* and decouples it from the body kind dispatcher (which
|
|
530
|
+
* applies per-section, not per-mode).
|
|
531
|
+
*
|
|
532
|
+
* @return {Array<object>}
|
|
533
|
+
*/
|
|
534
|
+
activeSections() {
|
|
535
|
+
if (!this.hasTabs) return this.sections || []
|
|
536
|
+
const active = this.tabs.find(t => t && t.id === this.activeTabId)
|
|
537
|
+
if (active && Array.isArray(active.sections)) return active.sections
|
|
538
|
+
// Defensive fallback — should not happen because
|
|
539
|
+
// `resolveInitialTabId` always lands on a known tab.
|
|
540
|
+
const first = this.tabs[0]
|
|
541
|
+
return first && Array.isArray(first.sections) ? first.sections : []
|
|
542
|
+
},
|
|
423
543
|
},
|
|
424
544
|
|
|
425
545
|
watch: {
|
|
@@ -430,6 +550,22 @@ export default {
|
|
|
430
550
|
this.originalData = this.cloneInitial()
|
|
431
551
|
},
|
|
432
552
|
},
|
|
553
|
+
// When `tabs[]` changes (e.g. consumer swaps manifests at
|
|
554
|
+
// runtime), re-resolve the active tab so the page doesn't get
|
|
555
|
+
// stuck on a removed id.
|
|
556
|
+
tabs: {
|
|
557
|
+
handler() {
|
|
558
|
+
this.activeTabId = this.resolveInitialTabId()
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
// When `initialTab` changes (consumer-controlled tab
|
|
562
|
+
// activation), follow it.
|
|
563
|
+
initialTab(next) {
|
|
564
|
+
if (typeof next === 'string' && next.length > 0) {
|
|
565
|
+
const exists = this.tabs.some(t => t && t.id === next)
|
|
566
|
+
if (exists) this.activeTabId = next
|
|
567
|
+
}
|
|
568
|
+
},
|
|
433
569
|
},
|
|
434
570
|
|
|
435
571
|
methods: {
|
|
@@ -475,11 +611,20 @@ export default {
|
|
|
475
611
|
},
|
|
476
612
|
cloneInitial() {
|
|
477
613
|
const merged = { ...(this.initialValues || {}) }
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
614
|
+
// Collect every section across both modes (flat
|
|
615
|
+
// `sections[]` AND `tabs[].sections[]`) so default values
|
|
616
|
+
// are applied regardless of orchestration shape.
|
|
617
|
+
// Only flat-field sections contribute defaults; component
|
|
618
|
+
// and widgets sections own their own state.
|
|
619
|
+
const allSections = []
|
|
620
|
+
for (const section of this.sections || []) allSections.push(section)
|
|
621
|
+
for (const tab of this.tabs || []) {
|
|
622
|
+
if (tab && Array.isArray(tab.sections)) {
|
|
623
|
+
for (const section of tab.sections) allSections.push(section)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
for (const section of allSections) {
|
|
627
|
+
if (!section || !Array.isArray(section.fields)) continue
|
|
483
628
|
for (const field of section.fields) {
|
|
484
629
|
if (field.default !== undefined && merged[field.key] === undefined) {
|
|
485
630
|
merged[field.key] = field.default
|
|
@@ -545,23 +690,55 @@ export default {
|
|
|
545
690
|
},
|
|
546
691
|
|
|
547
692
|
/**
|
|
548
|
-
* Resolve a `widgets[]
|
|
549
|
-
* order:
|
|
693
|
+
* Resolve a single `widgets[]` entry to a concrete Vue
|
|
694
|
+
* component. Lookup order:
|
|
550
695
|
*
|
|
551
696
|
* 1. Built-in widget map (`version-info`, `register-mapping`).
|
|
552
|
-
* 2. `
|
|
697
|
+
* 2. `'component'` discriminator (REQ-MSO-6) — resolves
|
|
698
|
+
* `widget.componentName` against `effectiveCustomComponents`.
|
|
699
|
+
* 3. Legacy fallback — looks up `widget.type` against
|
|
700
|
+
* `effectiveCustomComponents`. Kept for back-compat with
|
|
701
|
+
* manifest-settings-rich-sections consumers; flagged as
|
|
702
|
+
* deprecated in JSDoc — manifest authors should migrate to
|
|
703
|
+
* the explicit `{ type: "component", componentName }` shape.
|
|
553
704
|
*
|
|
554
|
-
* Returns `null` (and warns) when
|
|
555
|
-
* win on collision so consumers can't accidentally shadow them
|
|
556
|
-
* (REQ-MSRS-2).
|
|
705
|
+
* Returns `null` (and warns) when nothing resolves. Built-ins
|
|
706
|
+
* win on collision so consumers can't accidentally shadow them.
|
|
557
707
|
*
|
|
558
|
-
* @param {
|
|
708
|
+
* @param {object} widget A `widgets[]` entry, e.g. `{ type, props?, componentName? }`.
|
|
559
709
|
* @return {object|null} Vue component or null.
|
|
560
710
|
*/
|
|
561
|
-
resolveWidgetComponent(
|
|
711
|
+
resolveWidgetComponent(widget) {
|
|
712
|
+
const type = widget && typeof widget.type === 'string' ? widget.type : ''
|
|
713
|
+
if (!type) return null
|
|
562
714
|
if (Object.prototype.hasOwnProperty.call(BUILTIN_SETTINGS_WIDGETS, type)) {
|
|
563
|
-
|
|
715
|
+
const builtin = BUILTIN_SETTINGS_WIDGETS[type]
|
|
716
|
+
if (builtin === COMPONENT_DISCRIMINATOR) {
|
|
717
|
+
// REQ-MSO-6: discriminator — look up `componentName`.
|
|
718
|
+
const name = widget.componentName
|
|
719
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
720
|
+
// eslint-disable-next-line no-console
|
|
721
|
+
console.warn(
|
|
722
|
+
'[CnSettingsPage] Widget {type:"component"} requires a non-empty `componentName`. Widget will be skipped.',
|
|
723
|
+
)
|
|
724
|
+
return null
|
|
725
|
+
}
|
|
726
|
+
const resolved = this.effectiveCustomComponents[name]
|
|
727
|
+
if (!resolved) {
|
|
728
|
+
// eslint-disable-next-line no-console
|
|
729
|
+
console.warn(
|
|
730
|
+
`[CnSettingsPage] Widget component "${name}" not found in customComponents registry. Widget will be skipped.`,
|
|
731
|
+
)
|
|
732
|
+
return null
|
|
733
|
+
}
|
|
734
|
+
return resolved
|
|
735
|
+
}
|
|
736
|
+
return builtin
|
|
564
737
|
}
|
|
738
|
+
// Legacy fallback (manifest-settings-rich-sections REQ-MSRS-2).
|
|
739
|
+
// Deprecated — manifest authors should migrate to
|
|
740
|
+
// `{ type: "component", componentName: <X> }`. Kept here so
|
|
741
|
+
// existing consumers continue working unchanged.
|
|
565
742
|
const resolved = this.effectiveCustomComponents[type]
|
|
566
743
|
if (!resolved) {
|
|
567
744
|
// eslint-disable-next-line no-console
|
|
@@ -580,6 +757,11 @@ export default {
|
|
|
580
757
|
* has already logged a warn. The filter happens here so the
|
|
581
758
|
* template can use a clean `v-for` without nested `v-if`.
|
|
582
759
|
*
|
|
760
|
+
* The `widgetType` carried on the bubbled `@widget-event`
|
|
761
|
+
* payload is the widget's `componentName` (when the
|
|
762
|
+
* discriminator is `'component'`) or `widget.type` otherwise —
|
|
763
|
+
* giving consumers a stable identifier for the dispatch.
|
|
764
|
+
*
|
|
583
765
|
* @param {object} section A section entry with `widgets[]`.
|
|
584
766
|
* @param {number} sectionIndex Index in `sections[]`.
|
|
585
767
|
* @return {Array<{key: string, component: object, props: object, widgetType: string, widgetIndex: number}>}
|
|
@@ -589,19 +771,57 @@ export default {
|
|
|
589
771
|
const widgets = Array.isArray(section.widgets) ? section.widgets : []
|
|
590
772
|
for (let widgetIndex = 0; widgetIndex < widgets.length; widgetIndex++) {
|
|
591
773
|
const widget = widgets[widgetIndex] || {}
|
|
592
|
-
const component = this.resolveWidgetComponent(widget
|
|
774
|
+
const component = this.resolveWidgetComponent(widget)
|
|
593
775
|
if (!component) continue
|
|
776
|
+
const widgetType = widget.type === 'component' && typeof widget.componentName === 'string'
|
|
777
|
+
? widget.componentName
|
|
778
|
+
: widget.type
|
|
594
779
|
entries.push({
|
|
595
780
|
key: `widget-${sectionIndex}-${widgetIndex}`,
|
|
596
781
|
component,
|
|
597
782
|
props: widget.props || {},
|
|
598
|
-
widgetType
|
|
783
|
+
widgetType,
|
|
599
784
|
widgetIndex,
|
|
600
785
|
})
|
|
601
786
|
}
|
|
602
787
|
return entries
|
|
603
788
|
},
|
|
604
789
|
|
|
790
|
+
/**
|
|
791
|
+
* Resolve the active-tab id on mount / when `tabs[]` changes.
|
|
792
|
+
* Lookup order: explicit `initialTab` prop → first tab in
|
|
793
|
+
* `tabs[]` → empty string. Unknown `initialTab` values fall
|
|
794
|
+
* back to the first tab so the page never gets stuck.
|
|
795
|
+
* (manifest-settings-orchestration REQ-MSO-5.)
|
|
796
|
+
*
|
|
797
|
+
* @return {string} The resolved tab id (empty in flat mode).
|
|
798
|
+
*/
|
|
799
|
+
resolveInitialTabId() {
|
|
800
|
+
if (!this.hasTabs) return ''
|
|
801
|
+
if (typeof this.initialTab === 'string' && this.initialTab.length > 0) {
|
|
802
|
+
const exists = this.tabs.some(t => t && t.id === this.initialTab)
|
|
803
|
+
if (exists) return this.initialTab
|
|
804
|
+
}
|
|
805
|
+
const first = this.tabs[0]
|
|
806
|
+
return first && typeof first.id === 'string' ? first.id : ''
|
|
807
|
+
},
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Handle a tab button click. Switches the active tab and
|
|
811
|
+
* emits `@tab-change` so consumers can react (e.g. persist the
|
|
812
|
+
* active tab in their preference store, update the URL hash).
|
|
813
|
+
* (manifest-settings-orchestration REQ-MSO-5.)
|
|
814
|
+
*
|
|
815
|
+
* @param {object} tab The clicked tab definition.
|
|
816
|
+
* @param {number} tabIndex The tab's index in `tabs[]`.
|
|
817
|
+
*/
|
|
818
|
+
onTabClick(tab, tabIndex) {
|
|
819
|
+
if (!tab || typeof tab.id !== 'string') return
|
|
820
|
+
if (this.activeTabId === tab.id) return
|
|
821
|
+
this.activeTabId = tab.id
|
|
822
|
+
this.$emit('tab-change', { tabId: tab.id, tabIndex })
|
|
823
|
+
},
|
|
824
|
+
|
|
605
825
|
/**
|
|
606
826
|
* Re-emit a widget's `widget-event` (caught by the local
|
|
607
827
|
* CnSettingsWidgetMount helper) as a top-level `widget-event`
|
|
@@ -653,6 +873,65 @@ export default {
|
|
|
653
873
|
gap: 8px;
|
|
654
874
|
}
|
|
655
875
|
|
|
876
|
+
/*
|
|
877
|
+
* Tab strip for the orchestration shape (manifest-settings-
|
|
878
|
+
* orchestration REQ-MSO-5). Uses Nextcloud CSS variables only — no
|
|
879
|
+
* hex literals, no rgba overrides on the elements themselves.
|
|
880
|
+
*/
|
|
881
|
+
.cn-settings-page__tabs {
|
|
882
|
+
display: flex;
|
|
883
|
+
flex-wrap: wrap;
|
|
884
|
+
gap: 4px;
|
|
885
|
+
border-bottom: 1px solid var(--color-border);
|
|
886
|
+
padding-bottom: 0;
|
|
887
|
+
margin-bottom: 8px;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.cn-settings-page__tab {
|
|
891
|
+
background: transparent;
|
|
892
|
+
border: 0;
|
|
893
|
+
border-bottom: 3px solid transparent;
|
|
894
|
+
padding: 8px 16px;
|
|
895
|
+
margin: 0;
|
|
896
|
+
cursor: pointer;
|
|
897
|
+
color: var(--color-text-maxcontrast);
|
|
898
|
+
font-weight: normal;
|
|
899
|
+
border-radius: var(--border-radius-large) var(--border-radius-large) 0 0;
|
|
900
|
+
transition: background-color 0.1s ease-in-out, color 0.1s ease-in-out, border-color 0.1s ease-in-out;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
.cn-settings-page__tab:hover,
|
|
904
|
+
.cn-settings-page__tab:focus-visible {
|
|
905
|
+
background-color: var(--color-background-hover);
|
|
906
|
+
color: var(--color-main-text);
|
|
907
|
+
outline: none;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.cn-settings-page__tab--active {
|
|
911
|
+
color: var(--color-primary-element);
|
|
912
|
+
border-bottom-color: var(--color-primary-element);
|
|
913
|
+
font-weight: bold;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
@media (max-width: 768px) {
|
|
917
|
+
.cn-settings-page__tabs {
|
|
918
|
+
flex-direction: column;
|
|
919
|
+
gap: 0;
|
|
920
|
+
border-bottom: 0;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.cn-settings-page__tab {
|
|
924
|
+
border-bottom: 1px solid var(--color-border);
|
|
925
|
+
border-radius: 0;
|
|
926
|
+
text-align: left;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
.cn-settings-page__tab--active {
|
|
930
|
+
border-bottom-width: 1px;
|
|
931
|
+
background-color: var(--color-background-hover);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
656
935
|
.cn-settings-page__fields {
|
|
657
936
|
display: flex;
|
|
658
937
|
flex-direction: column;
|
|
@@ -2,19 +2,26 @@ import { ref } from 'vue'
|
|
|
2
2
|
import axios from '@nextcloud/axios'
|
|
3
3
|
import { generateUrl } from '@nextcloud/router'
|
|
4
4
|
import { validateManifest } from '../utils/validateManifest.js'
|
|
5
|
+
import { resolveManifestSentinels } from '../utils/resolveManifestSentinels.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* Composable that loads and validates a Conduction app manifest.
|
|
8
|
+
* Composable that loads, resolves, and validates a Conduction app manifest.
|
|
8
9
|
*
|
|
9
|
-
* The composable implements the
|
|
10
|
-
* REQ-JMR-002 of the json-manifest-renderer capability
|
|
10
|
+
* The composable implements the four-phase flow specified in
|
|
11
|
+
* REQ-JMR-002 of the json-manifest-renderer capability + the
|
|
12
|
+
* substitution step from the `manifest-resolve-sentinel` capability:
|
|
11
13
|
*
|
|
12
14
|
* 1. Synchronous bundled load — `bundledManifest` is the immediate value.
|
|
13
15
|
* 2. Async backend merge — fetches `/index.php/apps/{appId}/api/manifest`
|
|
14
16
|
* and deep-merges any 200 response over the bundled manifest. 4xx /
|
|
15
17
|
* 5xx / network errors are silently ignored so apps work without a
|
|
16
18
|
* backend endpoint.
|
|
17
|
-
* 3.
|
|
19
|
+
* 3. Sentinel resolution — `@resolve:<key>` strings under
|
|
20
|
+
* `pages[].config` are substituted with `IAppConfig` values via the
|
|
21
|
+
* `resolveManifestSentinels` utility (see its module docs for the
|
|
22
|
+
* resolution source chain). Unresolved keys surface on the
|
|
23
|
+
* returned `unresolvedSentinels` ref.
|
|
24
|
+
* 4. Validation — the resolved result is validated against
|
|
18
25
|
* `app-manifest.schema.json`. On failure, the bundled manifest is
|
|
19
26
|
* kept and a `console.warn` is emitted with the error list.
|
|
20
27
|
*
|
|
@@ -22,7 +29,8 @@ import { validateManifest } from '../utils/validateManifest.js'
|
|
|
22
29
|
* can hot-swap the manifest without a page reload.
|
|
23
30
|
*
|
|
24
31
|
* @param {string} appId Nextcloud app ID. Used to build the default
|
|
25
|
-
* backend endpoint URL via `@nextcloud/router
|
|
32
|
+
* backend endpoint URL via `@nextcloud/router` and to scope
|
|
33
|
+
* IAppConfig lookups for `@resolve:<key>` sentinels.
|
|
26
34
|
* @param {object} bundledManifest The manifest shipped with the app (the
|
|
27
35
|
* default value, available synchronously).
|
|
28
36
|
* @param {object} [options] Configuration options.
|
|
@@ -32,7 +40,10 @@ import { validateManifest } from '../utils/validateManifest.js'
|
|
|
32
40
|
* return a promise resolving to `{ status: number, data: object }`.
|
|
33
41
|
* Defaults to `axios.get` from `@nextcloud/axios` (which inherits the
|
|
34
42
|
* Nextcloud CSRF token automatically).
|
|
35
|
-
* @
|
|
43
|
+
* @param {Function} [options.getAppConfigValue] Override the
|
|
44
|
+
* IAppConfig resolver consumed by `resolveManifestSentinels`. Useful
|
|
45
|
+
* for tests that want to mount a fixture-driven config map.
|
|
46
|
+
* @return {{ manifest: import('vue').Ref<object>, isLoading: import('vue').Ref<boolean>, validationErrors: import('vue').Ref<string[]|null>, unresolvedSentinels: import('vue').Ref<string[]> }}
|
|
36
47
|
*
|
|
37
48
|
* @example Basic usage (Composition API)
|
|
38
49
|
* const { manifest, isLoading } = useAppManifest('decidesk', bundled)
|
|
@@ -49,11 +60,17 @@ import { validateManifest } from '../utils/validateManifest.js'
|
|
|
49
60
|
* endpoint: '/custom/manifest/url',
|
|
50
61
|
* fetcher: (url) => Promise.resolve({ status: 200, data: { ... } }),
|
|
51
62
|
* })
|
|
63
|
+
*
|
|
64
|
+
* @example With sentinel resolution + admin warning surface
|
|
65
|
+
* const { manifest, unresolvedSentinels } = useAppManifest('softwarecatalog', bundled)
|
|
66
|
+
* // unresolvedSentinels.value is e.g. ['voorzieningen_register']
|
|
67
|
+
* // when that IAppConfig key is unset on the tenant.
|
|
52
68
|
*/
|
|
53
69
|
export function useAppManifest(appId, bundledManifest, options = {}) {
|
|
54
70
|
const manifest = ref(bundledManifest)
|
|
55
71
|
const isLoading = ref(true)
|
|
56
72
|
const validationErrors = ref(null)
|
|
73
|
+
const unresolvedSentinels = ref([])
|
|
57
74
|
|
|
58
75
|
const endpoint = options.endpoint ?? generateUrl(`/apps/${appId}/api/manifest`)
|
|
59
76
|
const fetcher = options.fetcher ?? ((url) => axios.get(url))
|
|
@@ -65,7 +82,18 @@ export function useAppManifest(appId, bundledManifest, options = {}) {
|
|
|
65
82
|
return
|
|
66
83
|
}
|
|
67
84
|
const merged = deepMerge(bundledManifest, response.data)
|
|
68
|
-
|
|
85
|
+
|
|
86
|
+
// Sentinel resolution runs BEFORE validation per
|
|
87
|
+
// REQ-MRS-002: the validator MUST NEVER observe an
|
|
88
|
+
// unresolved sentinel at runtime. Resolution failures
|
|
89
|
+
// (unset IAppConfig keys) substitute null and accumulate
|
|
90
|
+
// on `unresolvedSentinels`; they do NOT block validation.
|
|
91
|
+
const { manifest: resolved, unresolved } = await resolveManifestSentinels(merged, appId, {
|
|
92
|
+
getAppConfigValue: options.getAppConfigValue,
|
|
93
|
+
})
|
|
94
|
+
unresolvedSentinels.value = unresolved
|
|
95
|
+
|
|
96
|
+
const result = validateManifest(resolved)
|
|
69
97
|
if (!result.valid) {
|
|
70
98
|
validationErrors.value = result.errors
|
|
71
99
|
// eslint-disable-next-line no-console
|
|
@@ -75,7 +103,7 @@ export function useAppManifest(appId, bundledManifest, options = {}) {
|
|
|
75
103
|
)
|
|
76
104
|
return
|
|
77
105
|
}
|
|
78
|
-
manifest.value =
|
|
106
|
+
manifest.value = resolved
|
|
79
107
|
} catch (err) {
|
|
80
108
|
// Silent fallback on 404, network errors, non-200 responses.
|
|
81
109
|
// Apps without a backend endpoint should keep working.
|
|
@@ -84,7 +112,7 @@ export function useAppManifest(appId, bundledManifest, options = {}) {
|
|
|
84
112
|
}
|
|
85
113
|
})()
|
|
86
114
|
|
|
87
|
-
return { manifest, isLoading, validationErrors }
|
|
115
|
+
return { manifest, isLoading, validationErrors, unresolvedSentinels }
|
|
88
116
|
}
|
|
89
117
|
|
|
90
118
|
/**
|
package/src/index.js
CHANGED
|
@@ -119,4 +119,5 @@ export { registerTranslations } from './l10n/index.js'
|
|
|
119
119
|
export { buildHeaders, buildQueryString, parseResponseError, networkError, genericError } from './utils/index.js'
|
|
120
120
|
export { columnsFromSchema, formatValue, filtersFromSchema, fieldsFromSchema, validateValue } from './utils/index.js'
|
|
121
121
|
export { validateManifest } from './utils/validateManifest.js'
|
|
122
|
+
export { resolveManifestSentinels, clearResolveCache } from './utils/resolveManifestSentinels.js'
|
|
122
123
|
export { filterWidgetsByVisibility, isWidgetVisible, getCurrentUserId, getCurrentUserGroups, resetVisibilityCache } from './utils/index.js'
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
},
|
|
128
128
|
"config": {
|
|
129
129
|
"type": "object",
|
|
130
|
-
"description": "Type-specific configuration. For type='index': { register, schema, columns, actions, sidebar?, cardComponent? }. The optional `cardComponent` is a string referencing a key in the consuming app's `customComponents` registry (the same registry that powers `type:'custom'` pages). When set AND the page is in card-grid view mode AND the parent has not provided a `#card` scoped slot, CnIndexPage mounts the registry-resolved component for each row instead of the schema-driven CnObjectCard, passing `{ item, object, schema, register, selected }` props and forwarding `click` + `select` events. An unknown name logs a console warning and falls back to CnObjectCard so a misconfigured manifest never blanks the grid. The optional `sidebar` is an object `{ enabled: boolean, show?: boolean, columnGroups?: array, facets?: object, showMetadata?: boolean, search?: object }` that, when `enabled`, makes CnIndexPage auto-mount its embedded CnIndexSidebar. `show` (default true) suppresses the embedded sidebar without removing config when set false. For type='detail': { register, schema, sidebar?, sidebarProps? }. The `sidebar` field accepts EITHER a Boolean (legacy: true/false toggles the external CnObjectSidebar) OR an Object mirroring the index shape plus detail-specific fields `{ show?: boolean, enabled?: boolean, register?, schema?, hiddenTabs?, title?, subtitle?, tabs? }`. `config.sidebar.show: false` suppresses the embedded sidebar on either index or detail pages. The optional `sidebarProps.tabs` is an open-enum array of tab definitions `{ id, label, icon?, widgets?, component?, order? }` that overrides CnObjectSidebar's hard-coded built-in tab set; each tab declares either a list of widgets (`type: 'data' | 'metadata' | <registry-name>`) or a registry component name. For type='dashboard': { widgets, layout }. For type='logs': { register?, schema?, source?, columns? } — one of register+schema OR source MUST be set. For type='settings': { sections
|
|
130
|
+
"description": "Type-specific configuration. For type='index': { register, schema, columns, actions, sidebar?, cardComponent? }. The optional `cardComponent` is a string referencing a key in the consuming app's `customComponents` registry (the same registry that powers `type:'custom'` pages). When set AND the page is in card-grid view mode AND the parent has not provided a `#card` scoped slot, CnIndexPage mounts the registry-resolved component for each row instead of the schema-driven CnObjectCard, passing `{ item, object, schema, register, selected }` props and forwarding `click` + `select` events. An unknown name logs a console warning and falls back to CnObjectCard so a misconfigured manifest never blanks the grid. The optional `sidebar` is an object `{ enabled: boolean, show?: boolean, columnGroups?: array, facets?: object, showMetadata?: boolean, search?: object }` that, when `enabled`, makes CnIndexPage auto-mount its embedded CnIndexSidebar. `show` (default true) suppresses the embedded sidebar without removing config when set false. For type='detail': { register, schema, sidebar?, sidebarProps? }. The `sidebar` field accepts EITHER a Boolean (legacy: true/false toggles the external CnObjectSidebar) OR an Object mirroring the index shape plus detail-specific fields `{ show?: boolean, enabled?: boolean, register?, schema?, hiddenTabs?, title?, subtitle?, tabs? }`. `config.sidebar.show: false` suppresses the embedded sidebar on either index or detail pages. The optional `sidebarProps.tabs` is an open-enum array of tab definitions `{ id, label, icon?, widgets?, component?, order? }` that overrides CnObjectSidebar's hard-coded built-in tab set; each tab declares either a list of widgets (`type: 'data' | 'metadata' | <registry-name>`) or a registry component name. For type='dashboard': { widgets, layout }. For type='logs': { register?, schema?, source?, columns? } — one of register+schema OR source MUST be set. For type='settings': { sections?: array<Section>, tabs?: array<Tab>, saveEndpoint? } where EXACTLY ONE of `sections` or `tabs` MUST be set (XOR — see manifest-settings-orchestration spec). Each Section declares EXACTLY ONE of `fields[]` (back-compat flat-field body), `component: <registry-name>` + optional `props` (mounts a customComponents-resolved component as the section body), OR `widgets: array<{ type, props?, componentName? }>` (mounts one or more widgets in sequence; built-in widget types `version-info` → CnVersionInfoCard, `register-mapping` → CnRegisterMapping, and `component` (with `componentName: <registry-name>`) → customComponents-resolved component). Each Tab is `{ id, label, icon?, sections: array<Section> }` and CnSettingsPage renders a tab strip switching between them. Widget events bubble through CnSettingsPage's `@widget-event` so consumers wire one page-level handler (see manifest-settings-rich-sections spec). For type='chat': { conversationSource?, postUrl?, schema? } — one of conversationSource OR postUrl MUST be set. For type='files': { folder, allowedTypes? }. For type='form': { fields: array<formField>, submitHandler? OR submitEndpoint?, submitMethod?, mode?, submitLabel?, successMessage?, initialValue? }. Exactly one of `submitHandler` (registry name resolved against customComponents) OR `submitEndpoint` (URL string; `:paramName` segments resolve against `$route.params`) MUST be set. `submitMethod` (default POST) MUST be one of POST | PUT | PATCH when set. `mode` (default public) MUST be one of edit | create | public when set. The `fields[]` array `$ref`s the same `formField` $def `pages[].config.sections[].fields[]` consumes for type='settings'. See manifest-form-page-type spec. For type='custom': any shape the custom component expects. As of schema version 1.2.0, the recurring sub-shapes (`columns[]`, `actions[]`, `widgets[]`, `layout[]`, `sections[].fields[]`, `sidebar.columnGroups[]`, `sidebar.tabs[]`, `sidebarProps.tabs[]`) `$ref` the seven `$defs` (`column`, `action`, `widgetDef`, `layoutItem`, `formField`, `sidebarSection`, `sidebarTab`). The OUTER `config` block keeps `additionalProperties: true` so per-type scalars (`register`, `schema`, `source`, `folder`, `saveEndpoint`, …) and consumer-app extension keys remain free-form.",
|
|
131
131
|
"additionalProperties": true,
|
|
132
132
|
"properties": {
|
|
133
133
|
"columns": {
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
},
|
|
163
163
|
"sections": {
|
|
164
164
|
"type": "array",
|
|
165
|
-
"description": "Settings sections consumed by CnSettingsPage (for type='settings'). Each section declares EXACTLY ONE of `fields[]` / `component` / `widgets[]` (FE-validated mutual exclusion). The outer section object keeps `additionalProperties: true`; only `fields[]` is typed via $ref formField. Settings widgets use a thinner shape `{ type, props? }` (NOT the same as dashboard widgetDef) and are NOT typed by this schema.",
|
|
165
|
+
"description": "Settings sections consumed by CnSettingsPage (for type='settings'). Each section declares EXACTLY ONE of `fields[]` / `component` / `widgets[]` (FE-validated mutual exclusion). The outer section object keeps `additionalProperties: true`; only `fields[]` is typed via $ref formField. Settings widgets use a thinner shape `{ type, props?, componentName? }` (NOT the same as dashboard widgetDef) and are NOT typed by this schema. Mutually exclusive with `tabs[]` at the page level — see manifest-settings-orchestration.",
|
|
166
166
|
"items": {
|
|
167
167
|
"type": "object",
|
|
168
168
|
"additionalProperties": true,
|
|
@@ -175,6 +175,44 @@
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
},
|
|
178
|
+
"tabs": {
|
|
179
|
+
"type": "array",
|
|
180
|
+
"description": "Settings tabs consumed by CnSettingsPage (for type='settings'). When set, CnSettingsPage renders a tab strip and the active tab's `sections[]` flow into the same renderer used by the flat shape. Mutually exclusive with `sections[]` at the page level (XOR). Each tab is `{ id: string, label: string, icon?: string, sections: array<Section> }`. See manifest-settings-orchestration spec.",
|
|
181
|
+
"items": {
|
|
182
|
+
"type": "object",
|
|
183
|
+
"additionalProperties": true,
|
|
184
|
+
"required": ["id", "label", "sections"],
|
|
185
|
+
"properties": {
|
|
186
|
+
"id": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"description": "Tab identifier — addressable by `initialTab` and emitted in `@tab-change`. MUST be non-empty and unique within the page."
|
|
189
|
+
},
|
|
190
|
+
"label": {
|
|
191
|
+
"type": "string",
|
|
192
|
+
"description": "Tab button label (i18n translation key — passed through `translate()` if a translate prop is wired on CnSettingsPage)."
|
|
193
|
+
},
|
|
194
|
+
"icon": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"description": "Optional MDI component name prepended to the tab button (e.g. \"Cog\")."
|
|
197
|
+
},
|
|
198
|
+
"sections": {
|
|
199
|
+
"type": "array",
|
|
200
|
+
"description": "Sections rendered when this tab is active. Same shape and rules as the flat `sections[]` case.",
|
|
201
|
+
"items": {
|
|
202
|
+
"type": "object",
|
|
203
|
+
"additionalProperties": true,
|
|
204
|
+
"properties": {
|
|
205
|
+
"fields": {
|
|
206
|
+
"type": "array",
|
|
207
|
+
"description": "Flat field-body for this section. Each item references the `formField` $def.",
|
|
208
|
+
"items": { "$ref": "#/$defs/formField" }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
178
216
|
"sidebar": {
|
|
179
217
|
"description": "Index/detail sidebar configuration. For type='index': an object with `columnGroups[]` ($ref sidebarSection) plus open scalars (enabled, show, facets, search, showMetadata). For type='detail': either a Boolean (legacy on/off) OR an object whose `tabs[]` references the `sidebarTab` $def — the rest of the object (`register`, `schema`, `title`, `subtitle`, `hiddenTabs`, `show`, `enabled`) stays free-form via `additionalProperties: true`.",
|
|
180
218
|
"oneOf": [
|