@conduction/nextcloud-vue 1.0.0-beta.16 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "1.0.0-beta.16",
3
+ "version": "1.0.0-beta.18",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Conduction B.V. <info@conduction.nl>",
@@ -0,0 +1,494 @@
1
+ <!--
2
+ CnFormPage — Manifest-driven runtime form.
3
+
4
+ Renders a flat field set + submit button declared in
5
+ `pages[].config` for `type: "form"` pages. Closes the gap that
6
+ forces every consumer's runtime-form route (public surveys, "request
7
+ a quote" pages, ticket-create routes when no detail-page round-trip
8
+ is needed) onto `type: "custom"`.
9
+
10
+ Submit dispatch picks one of two paths based on which prop is set:
11
+
12
+ - `submitEndpoint` — `axios[method](url, formData)`. URL `:param`
13
+ segments are resolved against `$route.params`.
14
+ - `submitHandler` — looks the name up in the customComponents
15
+ registry and calls the resolved value with
16
+ `(formData, $route, $router)`.
17
+
18
+ Field rendering is delegated to `cnRenderFormField` from
19
+ `@conduction/nextcloud-vue/composables` so the same input set
20
+ CnSettingsPage uses (boolean, number, string, password, enum, json)
21
+ is available without duplication. `widget: "textarea"` overrides
22
+ the default string input.
23
+
24
+ Slots (mirrors CnSettingsPage):
25
+ - `#header` — overrides CnPageHeader. Scope `{ title, description }`.
26
+ - `#actions` — right-aligned actions area.
27
+ - `#field-<key>` — replaces the input for a specific field.
28
+ Scope `{ field, value, onInput }`.
29
+ - `#submit` — replaces the submit button. Scope
30
+ `{ submitting, dirty, submit }`.
31
+
32
+ Events:
33
+ - `@input` — `{ key, value }` on every field change.
34
+ - `@submit` — `formData` after successful submit.
35
+ - `@error` — error object after failed submit.
36
+
37
+ Spec: REQ-MFPT-* (manifest-form-page-type).
38
+ -->
39
+ <template>
40
+ <div class="cn-form-page" :data-mode="mode">
41
+ <!--
42
+ @slot header
43
+ @description Replaces the default `CnPageHeader`. Receives `{ title, description }` as scoped props
44
+ so a custom header can mirror the manifest-supplied labels.
45
+ -->
46
+ <slot
47
+ name="header"
48
+ :title="title"
49
+ :description="description">
50
+ <CnPageHeader
51
+ v-if="title"
52
+ :title="title"
53
+ :description="description" />
54
+ </slot>
55
+
56
+ <div v-if="$slots.actions || $scopedSlots.actions" class="cn-form-page__actions">
57
+ <!-- Optional slot for action buttons (back, cancel, etc.) rendered above the form. -->
58
+ <slot name="actions" />
59
+ </div>
60
+
61
+ <!-- Success banner -->
62
+ <div v-if="submitted" class="cn-form-page__success">
63
+ {{ resolveLabel(successMessage) }}
64
+ </div>
65
+
66
+ <!-- Form body -->
67
+ <form
68
+ v-if="!submitted || mode !== 'public'"
69
+ class="cn-form-page__form"
70
+ @submit.prevent="submit">
71
+ <div
72
+ v-for="field in fields"
73
+ :key="field.key"
74
+ class="cn-form-page__field"
75
+ :data-field-key="field.key">
76
+ <!--
77
+ @slot field-${field.key}
78
+ @description Per-field override slot. Replaces the auto-rendered input for one specific field.
79
+ Scoped props: `{ field, value, onInput }` — `onInput(v)` updates the field via `updateField`.
80
+ -->
81
+ <slot
82
+ :name="`field-${field.key}`"
83
+ :field="field"
84
+ :value="formData[field.key]"
85
+ :on-input="(v) => updateField(field.key, v)">
86
+ <component
87
+ :is="resolveFieldRender(field).tag"
88
+ v-if="resolveFieldRender(field)"
89
+ v-bind="resolveFieldRender(field).props"
90
+ v-on="resolveFieldRender(field).listeners">
91
+ <!-- NcCheckboxRadioSwitch puts its label in the slot -->
92
+ <template
93
+ v-if="resolveFieldRender(field).kind === 'boolean'">
94
+ {{ resolveFieldRender(field).labelText }}
95
+ </template>
96
+ </component>
97
+ </slot>
98
+ <small
99
+ v-if="field.help"
100
+ class="cn-form-page__field-help">
101
+ {{ resolveLabel(field.help) }}
102
+ </small>
103
+ </div>
104
+
105
+ <!-- Error -->
106
+ <p v-if="lastError" class="cn-form-page__error">
107
+ {{ lastError }}
108
+ </p>
109
+
110
+ <div class="cn-form-page__submit">
111
+ <!-- Replaces the default submit button. Scoped props: `{ submitting, dirty, submit }`. -->
112
+ <slot
113
+ name="submit"
114
+ :submitting="submitting"
115
+ :dirty="dirty"
116
+ :submit="submit">
117
+ <NcButton
118
+ type="primary"
119
+ native-type="submit"
120
+ :disabled="submitting">
121
+ <template #icon>
122
+ <NcLoadingIcon v-if="submitting" :size="20" />
123
+ <Send v-else :size="20" />
124
+ </template>
125
+ {{ resolveLabel(submitLabel) }}
126
+ </NcButton>
127
+ </slot>
128
+ </div>
129
+ </form>
130
+ </div>
131
+ </template>
132
+
133
+ <script>
134
+ import { translate as t } from '@nextcloud/l10n'
135
+ import axios from '@nextcloud/axios'
136
+ import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
137
+ import Send from 'vue-material-design-icons/Send.vue'
138
+ import { CnPageHeader } from '../CnPageHeader/index.js'
139
+ import { cnRenderFormField } from '../../composables/cnFormFieldRenderer.js'
140
+
141
+ const ALLOWED_METHODS = ['POST', 'PUT', 'PATCH']
142
+
143
+ /**
144
+ * Resolve `:param` segments in a URL string against `$route.params`.
145
+ * Mirrors the `:id` substitution Vue Router performs on its own
146
+ * routes; reused here because the manifest declares the URL as a
147
+ * static string and we want the runtime to fill the slot.
148
+ *
149
+ * @param {string} url URL template, e.g. `/api/survey/:token`.
150
+ * @param {object} params $route.params.
151
+ * @return {string}
152
+ */
153
+ function resolveParams(url, params) {
154
+ if (!url || !params) return url
155
+ return String(url).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (match, name) => {
156
+ const value = params[name]
157
+ return value === undefined || value === null ? match : encodeURIComponent(String(value))
158
+ })
159
+ }
160
+
161
+ /**
162
+ * @event submit Fired after a successful submit. Payload: `{ formData, response? }` — `response` is present in endpoint mode, omitted in handler mode.
163
+ * @event error Fired when submit fails. Payload: `{ error, formData }`.
164
+ * @event input Fired on every field-level update; payload is the full current `formData` object.
165
+ *
166
+ * @slot header Replaces the default `CnPageHeader`. Scoped props: `{ title, description }`.
167
+ * @slot actions Optional slot for action buttons rendered above the form. Hidden when empty.
168
+ * @slot field-${field.key} Per-field override slot. Replaces the auto-rendered input for one specific field. Scoped props: `{ field, value, onInput }`.
169
+ * @slot submit Replaces the default submit button. Scoped props: `{ submitting, dirty, submit }`.
170
+ */
171
+ export default {
172
+ name: 'CnFormPage',
173
+
174
+ components: {
175
+ CnPageHeader,
176
+ NcButton,
177
+ NcLoadingIcon,
178
+ Send,
179
+ },
180
+
181
+ inject: {
182
+ /**
183
+ * Custom-component registry from CnAppRoot. Used to resolve the
184
+ * `submitHandler` name to a concrete function. Defaults to an
185
+ * empty object when the page is mounted standalone.
186
+ *
187
+ * @type {object}
188
+ */
189
+ cnCustomComponents: { default: () => ({}) },
190
+ },
191
+
192
+ props: {
193
+ /** Form fields. Each MUST conform to the `formField` $def. */
194
+ fields: {
195
+ type: Array,
196
+ default: () => [],
197
+ },
198
+ /**
199
+ * Registered submit handler name. Resolves against the
200
+ * `cnCustomComponents` registry (or `customComponents` prop).
201
+ * Mutually exclusive with `submitEndpoint` at the validator
202
+ * level; the component itself prefers `submitEndpoint` when both
203
+ * are set so a stale manifest doesn't crash.
204
+ */
205
+ submitHandler: {
206
+ type: String,
207
+ default: '',
208
+ },
209
+ /**
210
+ * URL the form data is dispatched to. `:paramName` segments are
211
+ * resolved against `$route.params` at submit time.
212
+ */
213
+ submitEndpoint: {
214
+ type: String,
215
+ default: '',
216
+ },
217
+ /** HTTP method for endpoint mode. POST | PUT | PATCH. */
218
+ submitMethod: {
219
+ type: String,
220
+ default: 'POST',
221
+ validator: (v) => typeof v === 'string' && ALLOWED_METHODS.includes(v.toUpperCase()),
222
+ },
223
+ /**
224
+ * Form mode. `public` shows the success banner and hides the
225
+ * form on submit; `edit` and `create` keep the form mounted so
226
+ * the consumer can route away.
227
+ */
228
+ mode: {
229
+ type: String,
230
+ default: 'public',
231
+ validator: (v) => ['edit', 'create', 'public'].includes(v),
232
+ },
233
+ /** i18n key for the submit button label. */
234
+ submitLabel: {
235
+ type: String,
236
+ default: () => t('nextcloud-vue', 'Submit'),
237
+ },
238
+ /** i18n key for the success banner. */
239
+ successMessage: {
240
+ type: String,
241
+ default: () => t('nextcloud-vue', 'Thank you!'),
242
+ },
243
+ /** Pre-filled form state. Consumed by `mode: "edit"`. */
244
+ initialValue: {
245
+ type: Object,
246
+ default: () => ({}),
247
+ },
248
+ /** Page title. Forwarded to CnPageHeader. */
249
+ title: {
250
+ type: String,
251
+ default: '',
252
+ },
253
+ /** Page description. Forwarded to CnPageHeader. */
254
+ description: {
255
+ type: String,
256
+ default: '',
257
+ },
258
+ /**
259
+ * Optional translation function. When provided, applied to
260
+ * field labels, success messages, etc. Defaults to identity.
261
+ *
262
+ * @type {Function|null}
263
+ */
264
+ translate: {
265
+ type: Function,
266
+ default: null,
267
+ },
268
+ /**
269
+ * Optional explicit custom-component registry. When set, takes
270
+ * precedence over the injected `cnCustomComponents`. Mirrors
271
+ * the resolution order in CnPageRenderer / CnSettingsPage.
272
+ *
273
+ * @type {object|null}
274
+ */
275
+ customComponents: {
276
+ type: Object,
277
+ default: null,
278
+ },
279
+ },
280
+
281
+ /**
282
+ * Events:
283
+ * @event submit
284
+ * @description Fired after a successful submit (handler returned, or endpoint POST/PUT/PATCH succeeded).
285
+ * Payload is `{ formData, response? }` — `response` is present in endpoint mode, omitted in handler mode.
286
+ *
287
+ * @event error
288
+ * @description Fired when submit fails (handler threw, or endpoint returned a non-2xx response).
289
+ * Payload is `{ error, formData }`.
290
+ *
291
+ * @event input
292
+ * @description Fired on every field-level update; payload is the full current `formData` object.
293
+ * Useful for parent-side reactive previews / autosave hooks.
294
+ */
295
+ emits: ['submit', 'error', 'input'],
296
+
297
+ data() {
298
+ return {
299
+ formData: this.cloneInitial(),
300
+ submitting: false,
301
+ submitted: false,
302
+ lastError: null,
303
+ }
304
+ },
305
+
306
+ computed: {
307
+ /** Whether any field has changed since mount. */
308
+ dirty() {
309
+ return JSON.stringify(this.formData) !== JSON.stringify(this.cloneInitial())
310
+ },
311
+ /**
312
+ * Effective custom-component registry. Explicit prop wins over
313
+ * the injected value (mirrors CnPageRenderer's resolution).
314
+ *
315
+ * @return {object}
316
+ */
317
+ effectiveCustomComponents() {
318
+ return this.customComponents ?? this.cnCustomComponents ?? {}
319
+ },
320
+ },
321
+
322
+ watch: {
323
+ initialValue: {
324
+ deep: true,
325
+ handler() {
326
+ this.formData = this.cloneInitial()
327
+ },
328
+ },
329
+ },
330
+
331
+ methods: {
332
+ cloneInitial() {
333
+ try {
334
+ return JSON.parse(JSON.stringify(this.initialValue || {}))
335
+ } catch (_e) {
336
+ return {}
337
+ }
338
+ },
339
+
340
+ resolveLabel(key) {
341
+ if (!key) return ''
342
+ const fn = typeof this.translate === 'function' ? this.translate : (k) => k
343
+ return fn(key)
344
+ },
345
+
346
+ /**
347
+ * Resolve render bindings for a field by delegating to the
348
+ * shared `cnRenderFormField` helper. Memoised inline so the
349
+ * template can call it once per field per render without
350
+ * re-allocating bindings on unrelated re-renders.
351
+ *
352
+ * @param {object} field
353
+ * @return {object|null}
354
+ */
355
+ resolveFieldRender(field) {
356
+ return cnRenderFormField({
357
+ field,
358
+ value: this.formData[field.key],
359
+ onInput: (next) => this.updateField(field.key, next),
360
+ t: typeof this.translate === 'function' ? this.translate : null,
361
+ })
362
+ },
363
+
364
+ updateField(key, value) {
365
+ this.$set(this.formData, key, value)
366
+ /**
367
+ * Field-level update event.
368
+ *
369
+ * @event input
370
+ * @type {{key: string, value: any}}
371
+ */
372
+ this.$emit('input', { key, value })
373
+ },
374
+
375
+ /**
376
+ * Dispatch the submit. Picks endpoint mode when `submitEndpoint`
377
+ * is set, otherwise handler mode. When neither is set, emits
378
+ * `@error` with a clear message rather than no-op silently.
379
+ *
380
+ * @return {Promise<void>}
381
+ */
382
+ async submit() {
383
+ this.lastError = null
384
+ this.submitting = true
385
+ try {
386
+ if (this.submitEndpoint) {
387
+ await this.submitViaEndpoint()
388
+ } else if (this.submitHandler) {
389
+ await this.submitViaHandler()
390
+ } else {
391
+ throw new Error('CnFormPage: no submit destination configured (set submitHandler or submitEndpoint)')
392
+ }
393
+ this.submitted = true
394
+ /**
395
+ * Successful submit event. Payload is the full formData object.
396
+ *
397
+ * @event submit
398
+ * @type {object}
399
+ */
400
+ this.$emit('submit', this.formData)
401
+ } catch (err) {
402
+ this.lastError = err && err.message ? err.message : String(err)
403
+ /**
404
+ * Submit failure event. Payload is the thrown error / rejected reason.
405
+ *
406
+ * @event error
407
+ * @type {Error}
408
+ */
409
+ this.$emit('error', err)
410
+ } finally {
411
+ this.submitting = false
412
+ }
413
+ },
414
+
415
+ async submitViaEndpoint() {
416
+ const method = (this.submitMethod || 'POST').toLowerCase()
417
+ const url = resolveParams(this.submitEndpoint, this.$route?.params || {})
418
+ if (typeof axios[method] !== 'function') {
419
+ throw new Error(`CnFormPage: unsupported HTTP method "${this.submitMethod}"`)
420
+ }
421
+ await axios[method](url, this.formData)
422
+ },
423
+
424
+ async submitViaHandler() {
425
+ const handler = this.effectiveCustomComponents[this.submitHandler]
426
+ if (typeof handler !== 'function') {
427
+ // eslint-disable-next-line no-console
428
+ console.warn(
429
+ `[CnFormPage] handler "${this.submitHandler}" not found in customComponents (or not a function). Did you register it?`,
430
+ )
431
+ throw new Error(`CnFormPage: handler "${this.submitHandler}" not registered`)
432
+ }
433
+ await handler(this.formData, this.$route, this.$router)
434
+ },
435
+ },
436
+ }
437
+ </script>
438
+
439
+ <style>
440
+ .cn-form-page {
441
+ display: flex;
442
+ flex-direction: column;
443
+ gap: 1rem;
444
+ padding: 1rem;
445
+ max-width: 720px;
446
+ margin: 0 auto;
447
+ color: var(--color-main-text);
448
+ }
449
+
450
+ .cn-form-page__form {
451
+ display: flex;
452
+ flex-direction: column;
453
+ gap: 1rem;
454
+ }
455
+
456
+ .cn-form-page__field {
457
+ display: flex;
458
+ flex-direction: column;
459
+ gap: 0.25rem;
460
+ }
461
+
462
+ .cn-form-page__field-help {
463
+ color: var(--color-text-maxcontrast);
464
+ font-size: 0.85em;
465
+ }
466
+
467
+ .cn-form-page__error {
468
+ color: var(--color-error);
469
+ background: var(--color-error-hover, transparent);
470
+ padding: 0.5rem 0.75rem;
471
+ border-radius: var(--border-radius);
472
+ }
473
+
474
+ .cn-form-page__success {
475
+ color: var(--color-success-text, var(--color-main-text));
476
+ background: var(--color-success-hover, transparent);
477
+ padding: 1rem;
478
+ border-radius: var(--border-radius);
479
+ text-align: center;
480
+ }
481
+
482
+ .cn-form-page__actions {
483
+ display: flex;
484
+ justify-content: flex-end;
485
+ gap: 0.5rem;
486
+ }
487
+
488
+ .cn-form-page__submit {
489
+ display: flex;
490
+ justify-content: flex-start;
491
+ gap: 0.5rem;
492
+ margin-top: 0.5rem;
493
+ }
494
+ </style>
@@ -0,0 +1,4 @@
1
+ import CnFormPage from './CnFormPage.vue'
2
+
3
+ export default CnFormPage
4
+ export { CnFormPage }
@@ -393,13 +393,13 @@ softwarecatalog `Organisaties` page needs a profile-style card with
393
393
  logo, contactpersoon block, and a CTA button — register the card
394
394
  component on `CnAppRoot` and reference it by name in the manifest:
395
395
 
396
- ```js
396
+ ```js {static}
397
397
  // src/customComponents.js
398
398
  import OrganisatieCard from './components/cards/OrganisatieCard.vue'
399
399
  export const customComponents = { OrganisatieCard }
400
400
  ```
401
401
 
402
- ```vue
402
+ ```vue {static}
403
403
  <!-- App.vue -->
404
404
  <CnAppRoot :manifest="manifest" app-id="softwarecatalog" :custom-components="customComponents">
405
405
  <router-view />
@@ -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'