@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.
@@ -215,7 +215,7 @@
215
215
  <CnRowActions
216
216
  :actions="mergedActions"
217
217
  :row="row"
218
- @action="$emit('action', $event)" />
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="$emit('action', $event)" />
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="$emit('action', $event)"
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
- /** Merged actions: app-provided first, then built-in defaults */
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
- return [...this.actions, ...this.defaultActions]
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
  }
@@ -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
@@ -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