@conduction/nextcloud-vue 1.0.0-beta.17 → 1.0.0-beta.18

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.
@@ -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.",
@@ -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