@conduction/nextcloud-vue 1.0.0-beta.17 → 1.0.0-beta.19
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 +2685 -1589
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +49 -0
- package/dist/nextcloud-vue.esm.js +2624 -1528
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/CnFormPage/CnFormPage.vue +494 -0
- package/src/components/CnFormPage/index.js +4 -0
- package/src/components/CnIndexPage/CnIndexPage.md +1 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +153 -23
- package/src/components/CnPageRenderer/pageTypes.js +1 -0
- package/src/components/index.js +1 -0
- package/src/composables/cnFormFieldRenderer.js +256 -0
- package/src/composables/index.js +1 -0
- package/src/schemas/app-manifest.schema.json +16 -2
- package/src/utils/validateManifest.js +61 -0
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
<CnRowActions
|
|
216
216
|
:actions="mergedActions"
|
|
217
217
|
:row="row"
|
|
218
|
-
@action="
|
|
218
|
+
@action="onRowAction" />
|
|
219
219
|
</slot>
|
|
220
220
|
</template>
|
|
221
221
|
</CnDataTable>
|
|
@@ -257,7 +257,7 @@
|
|
|
257
257
|
<CnRowActions
|
|
258
258
|
:actions="mergedActions"
|
|
259
259
|
:row="object"
|
|
260
|
-
@action="
|
|
260
|
+
@action="onRowAction" />
|
|
261
261
|
</slot>
|
|
262
262
|
</template>
|
|
263
263
|
</CnCardGrid>
|
|
@@ -267,7 +267,7 @@
|
|
|
267
267
|
:open.sync="contextMenuOpen"
|
|
268
268
|
:actions="mergedActions"
|
|
269
269
|
:target-item="contextMenuRow"
|
|
270
|
-
@action="
|
|
270
|
+
@action="onRowAction"
|
|
271
271
|
@close="closeContextMenu" />
|
|
272
272
|
|
|
273
273
|
<!-- Pagination -->
|
|
@@ -433,14 +433,20 @@ export default {
|
|
|
433
433
|
CnIndexSidebar,
|
|
434
434
|
},
|
|
435
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Inject the customComponents registry from a CnAppRoot ancestor.
|
|
438
|
+
* Used by:
|
|
439
|
+
* - REQ-MAD-3 / REQ-MAD-8 (manifest-actions-dispatch): resolves
|
|
440
|
+
* `actions[].handler` registry names to functions called on
|
|
441
|
+
* row-action click.
|
|
442
|
+
* - The cardComponent + form-dialog override paths: when set, the
|
|
443
|
+
* prop-level `customComponents` wins, but the inject is the
|
|
444
|
+
* default. See `effectiveCustomComponents`.
|
|
445
|
+
*
|
|
446
|
+
* Falls back to an empty object so `CnIndexPage` works standalone
|
|
447
|
+
* (unit tests, isolated mount) without `CnAppRoot`.
|
|
448
|
+
*/
|
|
436
449
|
inject: {
|
|
437
|
-
/**
|
|
438
|
-
* Custom-component registry provided by `CnAppRoot`. Falls back
|
|
439
|
-
* to an empty object so `CnIndexPage` works when mounted
|
|
440
|
-
* without `CnAppRoot` (e.g. in unit tests). The explicit
|
|
441
|
-
* `customComponents` prop wins when set — see
|
|
442
|
-
* `effectiveCustomComponents`.
|
|
443
|
-
*/
|
|
444
450
|
cnCustomComponents: { default: () => ({}) },
|
|
445
451
|
},
|
|
446
452
|
|
|
@@ -783,6 +789,11 @@ export default {
|
|
|
783
789
|
* `cnCustomComponents`. Provided primarily so unit tests can
|
|
784
790
|
* pass a registry without mounting `CnAppRoot`.
|
|
785
791
|
*
|
|
792
|
+
* Used by:
|
|
793
|
+
* - `cardComponent` resolution (REQ-MCI from manifest-card-index)
|
|
794
|
+
* - `actions[].handler` registry name resolution (REQ-MAD-3 from
|
|
795
|
+
* manifest-actions-dispatch — handler funcs called on row-action click)
|
|
796
|
+
*
|
|
786
797
|
* @type {object|null}
|
|
787
798
|
*/
|
|
788
799
|
customComponents: {
|
|
@@ -887,9 +898,56 @@ export default {
|
|
|
887
898
|
return builtIn
|
|
888
899
|
},
|
|
889
900
|
|
|
890
|
-
/**
|
|
901
|
+
/**
|
|
902
|
+
* Effective customComponents registry — explicit prop wins over
|
|
903
|
+
* the injected ancestor registry. Used to:
|
|
904
|
+
* - Resolve `actions[].handler` registry names (REQ-MAD-3,
|
|
905
|
+
* manifest-actions-dispatch).
|
|
906
|
+
* - Resolve the `cardComponent` name for card-grid view (REQ-MCI,
|
|
907
|
+
* manifest-card-index).
|
|
908
|
+
*
|
|
909
|
+
* @return {object}
|
|
910
|
+
*/
|
|
911
|
+
effectiveCustomComponents() {
|
|
912
|
+
return this.customComponents ?? this.cnCustomComponents ?? {}
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Merged actions: app-provided first, then built-in defaults.
|
|
917
|
+
*
|
|
918
|
+
* REQ-MAD-3 / REQ-MAD-4 / REQ-MAD-5 / REQ-MAD-6 / REQ-MAD-7
|
|
919
|
+
* (manifest-actions-dispatch) — for any action whose `handler`
|
|
920
|
+
* is a string, resolve it through `resolveHandler()` so
|
|
921
|
+
* `CnRowActions` sees the same `{ handler: fn }` shape it does
|
|
922
|
+
* for built-in defaults. Function-typed handlers (the existing
|
|
923
|
+
* runtime path) pass through untouched.
|
|
924
|
+
*/
|
|
891
925
|
mergedActions() {
|
|
892
|
-
|
|
926
|
+
const dispatched = this.actions.map((action) => {
|
|
927
|
+
if (typeof action.handler === 'function') {
|
|
928
|
+
// Back-compat: programmatic function handler — keep as-is.
|
|
929
|
+
return action
|
|
930
|
+
}
|
|
931
|
+
if (typeof action.handler !== 'string' || action.handler.length === 0) {
|
|
932
|
+
// No handler → emit-only path (existing default).
|
|
933
|
+
return action
|
|
934
|
+
}
|
|
935
|
+
const isNone = action.handler === 'none'
|
|
936
|
+
const resolved = this.resolveHandler(action)
|
|
937
|
+
if (resolved) {
|
|
938
|
+
// `none` returns a sentinel no-op handler AND must
|
|
939
|
+
// suppress the `@action` emit; flag it so onRowAction
|
|
940
|
+
// can drop the bubbled event.
|
|
941
|
+
return isNone
|
|
942
|
+
? { ...action, handler: resolved, _dispatchSuppress: true }
|
|
943
|
+
: { ...action, handler: resolved }
|
|
944
|
+
}
|
|
945
|
+
// Either reserved keyword "emit" / unknown name / non-function
|
|
946
|
+
// registry entry → page emits @action only; no handler call.
|
|
947
|
+
const { handler, ...rest } = action
|
|
948
|
+
return rest
|
|
949
|
+
})
|
|
950
|
+
return [...dispatched, ...this.defaultActions]
|
|
893
951
|
},
|
|
894
952
|
|
|
895
953
|
hasRowActions() {
|
|
@@ -937,17 +995,6 @@ export default {
|
|
|
937
995
|
return (this.sidebar && this.sidebar.search) || {}
|
|
938
996
|
},
|
|
939
997
|
|
|
940
|
-
/**
|
|
941
|
-
* Effective customComponents registry used to resolve the
|
|
942
|
-
* `cardComponent` name. Explicit prop wins, inject falls back,
|
|
943
|
-
* empty object is the last resort.
|
|
944
|
-
*
|
|
945
|
-
* @return {object}
|
|
946
|
-
*/
|
|
947
|
-
effectiveCustomComponents() {
|
|
948
|
-
return this.customComponents ?? this.cnCustomComponents ?? {}
|
|
949
|
-
},
|
|
950
|
-
|
|
951
998
|
/**
|
|
952
999
|
* Resolved card component for card-grid view mode. Returns
|
|
953
1000
|
* `null` when `cardComponent` is empty OR when the name is not
|
|
@@ -984,6 +1031,89 @@ export default {
|
|
|
984
1031
|
},
|
|
985
1032
|
|
|
986
1033
|
methods: {
|
|
1034
|
+
/**
|
|
1035
|
+
* REQ-MAD-3 / REQ-MAD-4 / REQ-MAD-5 / REQ-MAD-6 / REQ-MAD-7
|
|
1036
|
+
* (manifest-actions-dispatch) — Resolve a manifest-declared
|
|
1037
|
+
* action's `handler` string into a `(row) => void` invocation
|
|
1038
|
+
* function. Returns null when the action should fall back to
|
|
1039
|
+
* the page's `@action`-event-only path.
|
|
1040
|
+
*
|
|
1041
|
+
* - Reserved keyword `"navigate"` → push the configured route
|
|
1042
|
+
* with `params: { id: row[rowKey] }`.
|
|
1043
|
+
* - Reserved keyword `"emit"` → null (page still bubbles
|
|
1044
|
+
* `@action`; explicit no-op).
|
|
1045
|
+
* - Reserved keyword `"none"` → returns a no-op function that
|
|
1046
|
+
* suppresses both the handler and the `@action` emit. The
|
|
1047
|
+
* suppression happens via the special `_dispatchSuppress`
|
|
1048
|
+
* flag on the cloned action; see mergedActions for the
|
|
1049
|
+
* detail.
|
|
1050
|
+
* - Registry name → look up in `effectiveCustomComponents`;
|
|
1051
|
+
* when it's a function, wrap as
|
|
1052
|
+
* `(row) => fn({ actionId: action.id, item: row })`. When
|
|
1053
|
+
* it's a non-function, console.warn and return null.
|
|
1054
|
+
* - Unknown registry name → silent fall-through (null).
|
|
1055
|
+
*
|
|
1056
|
+
* @param {object} action The manifest-shaped action object.
|
|
1057
|
+
* @return {Function|null}
|
|
1058
|
+
*/
|
|
1059
|
+
resolveHandler(action) {
|
|
1060
|
+
const name = action.handler
|
|
1061
|
+
if (typeof name !== 'string' || name.length === 0) return null
|
|
1062
|
+
if (name === 'navigate') {
|
|
1063
|
+
const route = action.route
|
|
1064
|
+
if (typeof route !== 'string' || route.length === 0) {
|
|
1065
|
+
// eslint-disable-next-line no-console
|
|
1066
|
+
console.warn(
|
|
1067
|
+
`[CnIndexPage] action "${action.id}" declares handler:"navigate" `
|
|
1068
|
+
+ 'but route is missing; falling back to @action-only.',
|
|
1069
|
+
)
|
|
1070
|
+
return null
|
|
1071
|
+
}
|
|
1072
|
+
return (row) => {
|
|
1073
|
+
this.$router.push({
|
|
1074
|
+
name: route,
|
|
1075
|
+
params: { id: row[this.rowKey] },
|
|
1076
|
+
})
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (name === 'emit') return null
|
|
1080
|
+
if (name === 'none') {
|
|
1081
|
+
// Returns a sentinel that CnRowActions will treat as a
|
|
1082
|
+
// no-op; we additionally short-circuit @action emit in
|
|
1083
|
+
// `onRowAction` via the action's id.
|
|
1084
|
+
return () => {}
|
|
1085
|
+
}
|
|
1086
|
+
const fn = this.effectiveCustomComponents[name]
|
|
1087
|
+
if (typeof fn === 'function') {
|
|
1088
|
+
return (row) => fn({ actionId: action.id, item: row })
|
|
1089
|
+
}
|
|
1090
|
+
if (fn !== undefined) {
|
|
1091
|
+
// eslint-disable-next-line no-console
|
|
1092
|
+
console.warn(
|
|
1093
|
+
`[CnIndexPage] action.handler "${name}" resolved to a non-function in `
|
|
1094
|
+
+ 'customComponents — components belong to slot overrides; falling '
|
|
1095
|
+
+ 'back to @action-only.',
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
return null
|
|
1099
|
+
},
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* REQ-MAD-6 (manifest-actions-dispatch) — `handler: "none"`
|
|
1103
|
+
* blocks the `@action` emit entirely. CnRowActions emits
|
|
1104
|
+
* `@action` with `{ action: action.label, row }` and the page
|
|
1105
|
+
* forwards via `@action="$emit('action', $event)"`. This handler
|
|
1106
|
+
* intercepts so the `none`-flagged action is dropped before
|
|
1107
|
+
* re-emit.
|
|
1108
|
+
*
|
|
1109
|
+
* @param {{action: string, row: object}} payload The CnRowActions emit.
|
|
1110
|
+
*/
|
|
1111
|
+
onRowAction(payload) {
|
|
1112
|
+
const matched = this.mergedActions.find((a) => a.label === payload.action)
|
|
1113
|
+
if (matched && matched._dispatchSuppress) return
|
|
1114
|
+
this.$emit('action', payload)
|
|
1115
|
+
},
|
|
1116
|
+
|
|
987
1117
|
/**
|
|
988
1118
|
* Handle row click — emits row-click event for the parent to handle navigation.
|
|
989
1119
|
* @param {object} row The clicked row object
|
|
@@ -53,4 +53,5 @@ export const defaultPageTypes = {
|
|
|
53
53
|
settings: defineAsyncComponent(() => import('../CnSettingsPage/CnSettingsPage.vue').then(m => m.default)),
|
|
54
54
|
chat: defineAsyncComponent(() => import('../CnChatPage/CnChatPage.vue').then(m => m.default)),
|
|
55
55
|
files: defineAsyncComponent(() => import('../CnFilesPage/CnFilesPage.vue').then(m => m.default)),
|
|
56
|
+
form: defineAsyncComponent(() => import('../CnFormPage/CnFormPage.vue').then(m => m.default)),
|
|
56
57
|
}
|
package/src/components/index.js
CHANGED
|
@@ -60,6 +60,7 @@ export { CnLogsPage } from './CnLogsPage/index.js'
|
|
|
60
60
|
export { CnSettingsPage } from './CnSettingsPage/index.js'
|
|
61
61
|
export { CnChatPage } from './CnChatPage/index.js'
|
|
62
62
|
export { CnFilesPage } from './CnFilesPage/index.js'
|
|
63
|
+
export { CnFormPage } from './CnFormPage/index.js'
|
|
63
64
|
export { CnPageRenderer, defaultPageTypes } from './CnPageRenderer/index.js'
|
|
64
65
|
export { CnAppNav } from './CnAppNav/index.js'
|
|
65
66
|
export { CnAppLoading } from './CnAppLoading/index.js'
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cnFormFieldRenderer — shared field-renderer helper used by
|
|
3
|
+
* `CnFormPage` (and, in a follow-up, `CnSettingsPage`'s bare-fields
|
|
4
|
+
* branch).
|
|
5
|
+
*
|
|
6
|
+
* Maps a `formField` shape (the `$def` shared by
|
|
7
|
+
* `pages[].config.sections[].fields[]` for `type: "settings"` and
|
|
8
|
+
* `pages[].config.fields[]` for `type: "form"`) to one of the
|
|
9
|
+
* library's input components. Emitted to keep the input-rendering
|
|
10
|
+
* logic in one place — the settings page used to inline this switch
|
|
11
|
+
* (and still does, until a follow-up DRY pass migrates it); the form
|
|
12
|
+
* page consumes the shared helper from day one.
|
|
13
|
+
*
|
|
14
|
+
* Field shapes:
|
|
15
|
+
*
|
|
16
|
+
* | `field.type` | Component / behaviour |
|
|
17
|
+
* |---------------|----------------------------------------------------|
|
|
18
|
+
* | `boolean` | NcCheckboxRadioSwitch |
|
|
19
|
+
* | `number` | NcTextField type=number, value coerced to Number |
|
|
20
|
+
* | `password` | NcTextField type=password |
|
|
21
|
+
* | `string` | NcTextField (default), or NcTextArea when |
|
|
22
|
+
* | | `field.widget === "textarea"` |
|
|
23
|
+
* | `enum` | NcSelect, options shaped from `field.enum`/`field.options` |
|
|
24
|
+
* | `json` | CnJsonViewer (read-only display in this rev) |
|
|
25
|
+
*
|
|
26
|
+
* Unknown `field.type` values fall back to NcTextField and emit a
|
|
27
|
+
* single `console.warn` so the consumer notices the typo.
|
|
28
|
+
*
|
|
29
|
+
* The helper exports a render-function — components consume it via
|
|
30
|
+
* `<component :is="cnRenderFormField(...).renderer" v-bind="cnRenderFormField(...).bindings" />`
|
|
31
|
+
* — but for Vue 2 + the test toolchain we use a small functional
|
|
32
|
+
* resolver pattern: the helper returns `{ tag, props, listeners }`
|
|
33
|
+
* the parent template binds via `<component :is>`. This keeps the
|
|
34
|
+
* template trivially mountable in jest with the same component stubs
|
|
35
|
+
* tests already use for CnSettingsPage.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} args
|
|
38
|
+
* @param {object} args.field The formField shape.
|
|
39
|
+
* @param {*} args.value Current value for `field.key`.
|
|
40
|
+
* @param {Function} args.onInput Callback invoked with the new value.
|
|
41
|
+
* @param {Function} [args.t] Optional translator for `field.label`.
|
|
42
|
+
* @param {object} [args.componentMap] Optional override map from
|
|
43
|
+
* widget id → Vue component. Defaults to the library's standard
|
|
44
|
+
* set (`NcCheckboxRadioSwitch`, `NcTextField`, `NcTextArea`,
|
|
45
|
+
* `NcSelect`, `CnJsonViewer`).
|
|
46
|
+
*
|
|
47
|
+
* @return {{ tag: object, props: object, listeners: object }}
|
|
48
|
+
*/
|
|
49
|
+
import {
|
|
50
|
+
NcCheckboxRadioSwitch,
|
|
51
|
+
NcSelect,
|
|
52
|
+
NcTextField,
|
|
53
|
+
} from '@nextcloud/vue'
|
|
54
|
+
import CnJsonViewer from '../components/CnJsonViewer/CnJsonViewer.vue'
|
|
55
|
+
|
|
56
|
+
// NcTextArea is not always exported under the same path across
|
|
57
|
+
// @nextcloud/vue versions; falling back to NcTextField with a
|
|
58
|
+
// `multiline` prop hint keeps this resilient. The actual textarea
|
|
59
|
+
// rendering is delegated to the textarea fallback below.
|
|
60
|
+
let NcTextArea = null
|
|
61
|
+
try {
|
|
62
|
+
// eslint-disable-next-line global-require
|
|
63
|
+
NcTextArea = require('@nextcloud/vue').NcTextArea ?? null
|
|
64
|
+
} catch (_e) {
|
|
65
|
+
NcTextArea = null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DEFAULT_COMPONENT_MAP = Object.freeze({
|
|
69
|
+
boolean: NcCheckboxRadioSwitch,
|
|
70
|
+
number: NcTextField,
|
|
71
|
+
password: NcTextField,
|
|
72
|
+
string: NcTextField,
|
|
73
|
+
'string-textarea': NcTextArea,
|
|
74
|
+
enum: NcSelect,
|
|
75
|
+
json: CnJsonViewer,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const KNOWN_TYPES = ['boolean', 'number', 'password', 'string', 'enum', 'json']
|
|
79
|
+
|
|
80
|
+
const warned = new Set()
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Coerce an enum field's options into the `{ label, value }` shape
|
|
84
|
+
* NcSelect expects. Accepts:
|
|
85
|
+
* - `field.enum: ['a', 'b']` (preferred per the formField $def)
|
|
86
|
+
* - `field.options: [{ label, value }]` (legacy CnSettingsPage shape)
|
|
87
|
+
* - mixed `[{ label, value }, 'literal']`
|
|
88
|
+
*
|
|
89
|
+
* @param {object} field
|
|
90
|
+
* @return {Array<{label: string, value: *}>}
|
|
91
|
+
*/
|
|
92
|
+
function resolveEnumOptions(field) {
|
|
93
|
+
const raw = Array.isArray(field.enum)
|
|
94
|
+
? field.enum
|
|
95
|
+
: (Array.isArray(field.options) ? field.options : [])
|
|
96
|
+
return raw.map((entry) => {
|
|
97
|
+
if (entry && typeof entry === 'object' && 'value' in entry) {
|
|
98
|
+
return { label: String(entry.label ?? entry.value), value: entry.value }
|
|
99
|
+
}
|
|
100
|
+
return { label: String(entry), value: entry }
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve render bindings for a single form field.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} args See module docblock.
|
|
108
|
+
* @return {{ tag: object|string, props: object, listeners: object, kind: string }}
|
|
109
|
+
*/
|
|
110
|
+
export function cnRenderFormField({ field, value, onInput, t, componentMap } = {}) {
|
|
111
|
+
if (!field || typeof field !== 'object' || typeof field.key !== 'string') {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
const map = { ...DEFAULT_COMPONENT_MAP, ...(componentMap || {}) }
|
|
115
|
+
const translate = typeof t === 'function' ? t : (k) => k
|
|
116
|
+
const label = translate(field.label || field.key)
|
|
117
|
+
|
|
118
|
+
if (field.type === 'boolean') {
|
|
119
|
+
return {
|
|
120
|
+
kind: 'boolean',
|
|
121
|
+
tag: map.boolean,
|
|
122
|
+
props: {
|
|
123
|
+
checked: !!value,
|
|
124
|
+
label,
|
|
125
|
+
},
|
|
126
|
+
listeners: {
|
|
127
|
+
'update:checked': (next) => onInput(next),
|
|
128
|
+
},
|
|
129
|
+
labelText: label,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (field.type === 'number') {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'number',
|
|
136
|
+
tag: map.number,
|
|
137
|
+
props: {
|
|
138
|
+
label,
|
|
139
|
+
type: 'number',
|
|
140
|
+
value: value === null || value === undefined ? '' : String(value),
|
|
141
|
+
},
|
|
142
|
+
listeners: {
|
|
143
|
+
'update:value': (next) => onInput(next === '' ? null : Number(next)),
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (field.type === 'password') {
|
|
149
|
+
return {
|
|
150
|
+
kind: 'password',
|
|
151
|
+
tag: map.password,
|
|
152
|
+
props: {
|
|
153
|
+
label,
|
|
154
|
+
type: 'password',
|
|
155
|
+
value: value === null || value === undefined ? '' : String(value),
|
|
156
|
+
},
|
|
157
|
+
listeners: {
|
|
158
|
+
'update:value': (next) => onInput(next),
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (field.type === 'enum') {
|
|
164
|
+
const options = resolveEnumOptions(field)
|
|
165
|
+
const selected = options.find((o) => o.value === value) ?? null
|
|
166
|
+
return {
|
|
167
|
+
kind: 'enum',
|
|
168
|
+
tag: map.enum,
|
|
169
|
+
props: {
|
|
170
|
+
inputLabel: label,
|
|
171
|
+
options,
|
|
172
|
+
value: selected,
|
|
173
|
+
},
|
|
174
|
+
listeners: {
|
|
175
|
+
input: (next) => onInput(next?.value),
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (field.type === 'json') {
|
|
181
|
+
return {
|
|
182
|
+
kind: 'json',
|
|
183
|
+
tag: map.json,
|
|
184
|
+
props: {
|
|
185
|
+
value: value ?? null,
|
|
186
|
+
label,
|
|
187
|
+
},
|
|
188
|
+
listeners: {},
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (field.type === 'string') {
|
|
193
|
+
const isTextarea = field.widget === 'textarea'
|
|
194
|
+
if (isTextarea) {
|
|
195
|
+
// NcTextArea is preferred; otherwise fall back to a plain
|
|
196
|
+
// <textarea> rendered via the host template. The renderer
|
|
197
|
+
// returns `tag: 'textarea'` so the consumer's `<component :is>`
|
|
198
|
+
// resolves to the native element.
|
|
199
|
+
return {
|
|
200
|
+
kind: 'string-textarea',
|
|
201
|
+
tag: map['string-textarea'] || 'textarea',
|
|
202
|
+
props: {
|
|
203
|
+
label,
|
|
204
|
+
value: value === null || value === undefined ? '' : String(value),
|
|
205
|
+
rows: 4,
|
|
206
|
+
},
|
|
207
|
+
listeners: {
|
|
208
|
+
'update:value': (next) => onInput(next),
|
|
209
|
+
input: (event) => {
|
|
210
|
+
// Native textarea path — `event` is the InputEvent.
|
|
211
|
+
const next = event && event.target ? event.target.value : event
|
|
212
|
+
onInput(next)
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
kind: 'string',
|
|
219
|
+
tag: map.string,
|
|
220
|
+
props: {
|
|
221
|
+
label,
|
|
222
|
+
value: value === null || value === undefined ? '' : String(value),
|
|
223
|
+
},
|
|
224
|
+
listeners: {
|
|
225
|
+
'update:value': (next) => onInput(next),
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Unknown type — warn ONCE per type and fall back to NcTextField.
|
|
231
|
+
if (!KNOWN_TYPES.includes(field.type)) {
|
|
232
|
+
if (!warned.has(field.type)) {
|
|
233
|
+
warned.add(field.type)
|
|
234
|
+
// eslint-disable-next-line no-console
|
|
235
|
+
console.warn(
|
|
236
|
+
`[cnRenderFormField] Unknown field.type "${field.type}" for field "${field.key}". Falling back to NcTextField. Known types: ${KNOWN_TYPES.join(', ')}.`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
kind: 'fallback',
|
|
241
|
+
tag: map.string,
|
|
242
|
+
props: {
|
|
243
|
+
label,
|
|
244
|
+
value: value === null || value === undefined ? '' : String(value),
|
|
245
|
+
},
|
|
246
|
+
listeners: {
|
|
247
|
+
'update:value': (next) => onInput(next),
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Should be unreachable given KNOWN_TYPES check above.
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default cnRenderFormField
|
package/src/composables/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { useDashboardView } from './useDashboardView.js'
|
|
|
5
5
|
export { useContextMenu } from './useContextMenu.js'
|
|
6
6
|
export { useAppManifest } from './useAppManifest.js'
|
|
7
7
|
export { useAppStatus } from './useAppStatus.js'
|
|
8
|
+
export { cnRenderFormField } from './cnFormFieldRenderer.js'
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
},
|
|
120
120
|
"type": {
|
|
121
121
|
"type": "string",
|
|
122
|
-
"description": "Page type. Must match a key in the renderer's `pageTypes` registry (library defaults: \"index\", \"detail\", \"dashboard\", \"logs\", \"settings\", \"chat\", \"files\") OR be \"custom\" — in which case `component` resolves against the customComponents registry. Library extensions add their built-in types to `defaultPageTypes`; consumer apps pass a merged map via the `pageTypes` prop on CnAppRoot / CnPageRenderer."
|
|
122
|
+
"description": "Page type. Must match a key in the renderer's `pageTypes` registry (library defaults: \"index\", \"detail\", \"dashboard\", \"logs\", \"settings\", \"chat\", \"files\", \"form\") OR be \"custom\" — in which case `component` resolves against the customComponents registry. Library extensions add their built-in types to `defaultPageTypes`; consumer apps pass a merged map via the `pageTypes` prop on CnAppRoot / CnPageRenderer. The \"form\" type was added by `manifest-form-page-type` and renders a manifest-declared field set with submit/save handlers — see the type='form' note in `pages[].config` for the dispatch contract."
|
|
123
123
|
},
|
|
124
124
|
"title": {
|
|
125
125
|
"type": "string",
|
|
@@ -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: array<Section>, saveEndpoint? } where 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? }>` (mounts one or more widgets in sequence; built-in widget types `version-info` → CnVersionInfoCard and `register-mapping` → CnRegisterMapping resolve first, falling back to the customComponents registry). 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='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.",
|
|
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>, saveEndpoint? } where 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? }>` (mounts one or more widgets in sequence; built-in widget types `version-info` → CnVersionInfoCard and `register-mapping` → CnRegisterMapping resolve first, falling back to the customComponents registry). 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": {
|
|
@@ -155,6 +155,11 @@
|
|
|
155
155
|
"description": "Dashboard layout entries consumed by CnDashboardGrid / CnDashboardPage (for type='dashboard'). Each item references the `layoutItem` $def.",
|
|
156
156
|
"items": { "$ref": "#/$defs/layoutItem" }
|
|
157
157
|
},
|
|
158
|
+
"fields": {
|
|
159
|
+
"type": "array",
|
|
160
|
+
"description": "Form fields consumed by CnFormPage (for type='form'). Each item references the `formField` $def — the same shape `pages[].config.sections[].fields[]` uses for type='settings'.",
|
|
161
|
+
"items": { "$ref": "#/$defs/formField" }
|
|
162
|
+
},
|
|
158
163
|
"sections": {
|
|
159
164
|
"type": "array",
|
|
160
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.",
|
|
@@ -310,6 +315,15 @@
|
|
|
310
315
|
"type": "boolean",
|
|
311
316
|
"default": false,
|
|
312
317
|
"description": "When true the consumer SHOULD show a confirmation dialog before invoking the action. Useful for destructive actions; defaults to false."
|
|
318
|
+
},
|
|
319
|
+
"handler": {
|
|
320
|
+
"type": "string",
|
|
321
|
+
"description": "Optional dispatch target for the action. Either (a) one of the reserved keywords \"navigate\" / \"emit\" / \"none\", or (b) a registry name resolving to a function in the customComponents map passed to CnAppRoot. When the registry name resolves to a function, CnIndexPage / CnDetailPage call it with `{ actionId, item }` on row-action click. The reserved keywords short-circuit the registry lookup: \"navigate\" calls `$router.push({ name: action.route, params: { id: row[rowKey] } })`; \"emit\" emits `@action` only (semantic-explicit no-op); \"none\" disables the click entirely. When unset (the default), the action only emits `@action` and the page-level listener decides the side-effect — preserves v1.2 behaviour. Added in schema 1.3.0.",
|
|
322
|
+
"pattern": "^(navigate|emit|none|[A-Za-z][A-Za-z0-9_]*)$"
|
|
323
|
+
},
|
|
324
|
+
"route": {
|
|
325
|
+
"type": "string",
|
|
326
|
+
"description": "Vue-router route name dispatched when `handler === \"navigate\"`. Required for the navigate keyword; ignored for other handler values. CnIndexPage uses it as `$router.push({ name: action.route, params: { id: row[rowKey] } })`. Added in schema 1.3.0."
|
|
313
327
|
}
|
|
314
328
|
}
|
|
315
329
|
},
|
|
@@ -254,6 +254,42 @@ function validateTypeConfig(page, index, errors) {
|
|
|
254
254
|
}
|
|
255
255
|
break
|
|
256
256
|
}
|
|
257
|
+
case 'form': {
|
|
258
|
+
// `manifest-form-page-type` REQ-MFPT-* — runtime form pages
|
|
259
|
+
// MUST declare a non-empty fields[] array and exactly one of
|
|
260
|
+
// submitHandler | submitEndpoint as the dispatch destination.
|
|
261
|
+
// Optional submitMethod and mode are constrained to closed
|
|
262
|
+
// enums so manifest typos surface at validate time.
|
|
263
|
+
const hasFields = cfg && Array.isArray(cfg.fields) && cfg.fields.length > 0
|
|
264
|
+
if (!hasFields) {
|
|
265
|
+
errors.push(`${pathSlash}/fields: ${pathBracket}: form pages must declare a non-empty fields[] array`)
|
|
266
|
+
} else {
|
|
267
|
+
validateFieldsArray(cfg.fields, `${pathSlash}/fields`, errors)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const hasHandler = cfg && typeof cfg.submitHandler === 'string' && cfg.submitHandler.length > 0
|
|
271
|
+
const hasEndpoint = cfg && typeof cfg.submitEndpoint === 'string' && cfg.submitEndpoint.length > 0
|
|
272
|
+
const dispatchCount = (hasHandler ? 1 : 0) + (hasEndpoint ? 1 : 0)
|
|
273
|
+
if (dispatchCount !== 1) {
|
|
274
|
+
errors.push(`${pathSlash}: ${pathBracket}: form pages must declare exactly one of submitHandler | submitEndpoint`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (cfg && cfg.submitMethod !== undefined) {
|
|
278
|
+
const allowed = ['POST', 'PUT', 'PATCH']
|
|
279
|
+
const upper = typeof cfg.submitMethod === 'string' ? cfg.submitMethod.toUpperCase() : null
|
|
280
|
+
if (!upper || !allowed.includes(upper)) {
|
|
281
|
+
errors.push(`${pathSlash}/submitMethod: ${pathBracket}.submitMethod: must be one of POST | PUT | PATCH`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (cfg && cfg.mode !== undefined) {
|
|
286
|
+
const allowedModes = ['edit', 'create', 'public']
|
|
287
|
+
if (typeof cfg.mode !== 'string' || !allowedModes.includes(cfg.mode)) {
|
|
288
|
+
errors.push(`${pathSlash}/mode: ${pathBracket}.mode: must be one of edit | create | public`)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
break
|
|
292
|
+
}
|
|
257
293
|
default:
|
|
258
294
|
// No per-type rules for index/detail/dashboard/custom or
|
|
259
295
|
// consumer-defined types; their `config` shape is enforced
|
|
@@ -527,9 +563,34 @@ function validateActionsArray(cfg, pathSlash, pathBracket, errors) {
|
|
|
527
563
|
if (typeof action.label !== 'string' || action.label.length === 0) {
|
|
528
564
|
errors.push(`${actionPath}/label: must be a non-empty string`)
|
|
529
565
|
}
|
|
566
|
+
// REQ-MAD-1 / REQ-MAD-2 — `handler` (string, registry name OR
|
|
567
|
+
// reserved keyword `navigate`/`emit`/`none`) and the matching
|
|
568
|
+
// `navigate` requirement on `route`. Schema 1.3.0+.
|
|
569
|
+
if (action.handler !== undefined) {
|
|
570
|
+
if (typeof action.handler !== 'string') {
|
|
571
|
+
errors.push(`${actionPath}/handler: must be a string when set`)
|
|
572
|
+
} else if (!HANDLER_PATTERN.test(action.handler)) {
|
|
573
|
+
errors.push(
|
|
574
|
+
`${actionPath}/handler: "${action.handler}" must match `
|
|
575
|
+
+ '"navigate" | "emit" | "none" | [A-Za-z][A-Za-z0-9_]*',
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
if (action.handler === 'navigate'
|
|
579
|
+
&& (typeof action.route !== 'string' || action.route.length === 0)) {
|
|
580
|
+
errors.push(`${actionPath}/route: required when handler is "navigate"`)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
530
583
|
})
|
|
531
584
|
}
|
|
532
585
|
|
|
586
|
+
/**
|
|
587
|
+
* REQ-MAD-1 — Allowed shapes for `actions[].handler`. Either a
|
|
588
|
+
* reserved keyword (`navigate` | `emit` | `none`) or a JS-identifier
|
|
589
|
+
* registry name (alphanumeric + underscore, leading letter). Mirrors
|
|
590
|
+
* the schema's `pattern` on the `handler` property.
|
|
591
|
+
*/
|
|
592
|
+
const HANDLER_PATTERN = /^(navigate|emit|none|[A-Za-z][A-Za-z0-9_]*)$/
|
|
593
|
+
|
|
533
594
|
/**
|
|
534
595
|
* Validate `config.widgets[]` for dashboard page type
|
|
535
596
|
* (`manifest-config-refs` REQ-MCR). Each entry MUST be an object with
|