diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1785cc33b..5d71d91dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: # run: npm run test - name: Cypress run - uses: cypress-io/github-action@v6 + uses: cypress-io/github-action@v7.4.8 with: browser: chrome install: false diff --git a/src/components/index.js b/src/components/index.js index e001acd71..641ed5136 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -54,6 +54,8 @@ import { LinkButton, CaseProgressBar } from "./renderer"; import "../assets/css/tabs.css"; import FormCollectionRecordControl from "./renderer/form-collection-record-control.vue"; import FormCollectionViewControl from "./renderer/form-collection-view-control.vue"; +import VueRenderPreview from "./renderer/vueRender/VueRenderPreview.vue"; +import VueRenderEditor from "./renderer/vueRender/VueRenderEditor.vue"; const rendererComponents = { ...renderer, @@ -161,6 +163,8 @@ export default { Vue.component("VueFormRenderer", VueFormRenderer); Vue.component("DefaultLoadingSpinner", DefaultLoadingSpinner); Vue.use(VueFormElements); + Vue.component("VueRenderPreview", VueRenderPreview); + Vue.component("VueRenderEditor", VueRenderEditor); Vue.component("FormButton", FormButton); Vue.component("FileUpload", FileUpload); Vue.component("FileDownload", FileDownload); diff --git a/src/components/inspector/vueRender/EditorFieldInspector.vue b/src/components/inspector/vueRender/EditorFieldInspector.vue new file mode 100644 index 000000000..753303466 --- /dev/null +++ b/src/components/inspector/vueRender/EditorFieldInspector.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/src/components/renderer/vueRender/VueRenderEditor.vue b/src/components/renderer/vueRender/VueRenderEditor.vue new file mode 100644 index 000000000..6089343c4 --- /dev/null +++ b/src/components/renderer/vueRender/VueRenderEditor.vue @@ -0,0 +1,99 @@ + + + + diff --git a/src/components/renderer/vueRender/VueRenderPreview.vue b/src/components/renderer/vueRender/VueRenderPreview.vue new file mode 100644 index 000000000..399bf1860 --- /dev/null +++ b/src/components/renderer/vueRender/VueRenderPreview.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/renderer/vueRender/VueRenderTemplate.js b/src/components/renderer/vueRender/VueRenderTemplate.js new file mode 100644 index 000000000..eea68dea3 --- /dev/null +++ b/src/components/renderer/vueRender/VueRenderTemplate.js @@ -0,0 +1,245 @@ +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sanitizeHandlerPart(value, kind) { + if (!isPlainObject(value)) { + return {}; + } + + const sanitized = {}; + Object.keys(value).forEach((key) => { + const entry = value[key]; + if (entry === null || entry === undefined) { + return; + } + if (kind === 'methods' || kind === 'computed') { + if (typeof entry === 'function') { + sanitized[key] = entry; + } + return; + } + if (kind === 'watch') { + sanitized[key] = entry; + return; + } + if (kind === 'components') { + if (typeof entry === 'object' || typeof entry === 'function') { + sanitized[key] = entry; + } + } + }); + return sanitized; +} + +export default { + name: 'RenderTemplate', + inheritAttrs: false, + props: [ + 'error', + 'name', + 'value', + 'helper', + 'controlClass', + 'templateVue', + 'handlerVue', + 'validationData', + 'label', + 'renderVarHtml', + 'components', + ], + methods: { + /** + * Screen Builder binds v-model on this renderer when `config.name` is set. + * Forward nested data writes so `$v.vdata.` stays in sync. + */ + emitInput(nextValue) { + this.$emit('input', nextValue); + this.$emit('change', nextValue); + }, + resolveTransientData() { + return this.$attrs['transient-data'] + || this.$attrs.transientData + || this.validationData + || {}; + }, + resolveTemplateSource() { + const template = this.templateVue && String(this.templateVue).trim(); + return template || '
'; + }, + resolveHandlerOptions() { + const empty = { + methods: {}, + computed: {}, + watch: {}, + components: {}, + dataFn: null, + error: null, + }; + + if (!this.handlerVue || !String(this.handlerVue).trim()) { + return empty; + } + + try { + // eslint-disable-next-line no-new-func -- handlerVue is screen-authored in Screen Builder + const evaluated = new Function(this.handlerVue)(); // NOSONAR. Intentional: handlerVue is JS configured by authorized screen authors; result must be a plain object and only methods/computed/watch/components/data are used after sanitization. + if (!isPlainObject(evaluated)) { + return { + ...empty, + error: + 'handlerVue must return a plain object (Vue mixin). Example: return { methods: { ... } }', + }; + } + + let dataFn = null; + if (typeof evaluated.data === 'function') { + dataFn = evaluated.data; + } + + return { + methods: sanitizeHandlerPart(evaluated.methods, 'methods'), + computed: sanitizeHandlerPart(evaluated.computed, 'computed'), + watch: sanitizeHandlerPart(evaluated.watch, 'watch'), + components: sanitizeHandlerPart(evaluated.components, 'components'), + dataFn, + error: null, + }; + } catch (err) { + return { + ...empty, + error: err?.message || String(err), + }; + } + }, + }, + render(createElement) { + const handlerOptions = this.resolveHandlerOptions(); + const templateSource = this.resolveTemplateSource(); + const getTransientData = () => this.resolveTransientData(); + const emitSubmit = () => this.$emit('submit'); + + let component; + try { + component = createElement({ + components: { + ...this.components, + ...handlerOptions.components, + }, + template: templateSource, + data() { + const base = { + data: getTransientData(), + }; + if (!handlerOptions.dataFn) { + return base; + } + try { + const handlerData = handlerOptions.dataFn.call(this); + if (isPlainObject(handlerData)) { + return { ...base, ...handlerData }; + } + } catch (dataError) { + // eslint-disable-next-line no-console + console.warn('[VueRenderTemplate] handlerVue.data() failed', dataError); + } + return base; + }, + computed: { + ...handlerOptions.computed, + transientData: { + get: () => getTransientData(), + set(value) { + const target = getTransientData(); + if (target && typeof target === 'object') { + Object.assign(target, value); + } + }, + }, + }, + watch: { + ...handlerOptions.watch, + data(value) { + const target = getTransientData(); + if (!target || !value || typeof value !== 'object' || value === target) { + return; + } + Object.assign(target, value); + this.data = target; + }, + }, + methods: { + ...handlerOptions.methods, + submit: () => emitSubmit(), + getData: () => getTransientData() || {}, + updateTransientData(value) { + this.transientData = value; + }, + }, + }); + } catch (compileError) { + component = createElement( + 'div', + { class: 'invalid-feedback d-block', attrs: { role: 'alert' } }, + compileError?.message || String(compileError), + ); + } + + const children = [component]; + + const displayError = this.error || handlerOptions.error; + if (displayError) { + children.push(createElement( + 'div', + { + class: 'invalid-feedback d-block', + attrs: { role: 'alert' }, + }, + displayError, + )); + } + + if (this.helper) { + children.push(createElement( + 'small', + { class: 'form-text text-muted' }, + this.helper, + )); + } + + return createElement( + 'div', + { + class: { + 'form-group': true, + 'form-group--error': Boolean(displayError), + [this.controlClass]: Boolean(this.controlClass), + }, + }, + children, + ); + }, + mounted() { + if (!this.name) { + return; + } + const data = this.resolveTransientData(); + if (!data || typeof data !== 'object') { + return; + } + this.sbDataUnwatch = this.$watch( + () => data[this.name], + (nextValue) => { + if (nextValue !== this.value) { + this.emitInput(nextValue === undefined ? null : nextValue); + } + }, + { immediate: true }, + ); + }, + beforeDestroy() { + if (typeof this.sbDataUnwatch === 'function') { + this.sbDataUnwatch(); + } + }, +}; diff --git a/src/form-builder-controls.js b/src/form-builder-controls.js index a8d21216c..67cc720c1 100755 --- a/src/form-builder-controls.js +++ b/src/form-builder-controls.js @@ -29,6 +29,7 @@ import { import { dataSourceValues } from '@/components/inspector/data-source-types'; import LinkButton from "./components/renderer/link-button.vue"; import CaseProgressBar from "./components/renderer/case-progress-bar.vue"; +import EditorFieldInspector from './components/inspector/vueRender/EditorFieldInspector.vue'; import { bgcolorProperty, @@ -56,6 +57,71 @@ import { variantStyleProperty } from './form-control-common-properties'; +const VueComponentEditorConfig = { + editorComponent: 'VueRenderEditor', + editorBinding: 'VueRenderEditor', + rendererComponent: 'VueRenderPreview', + rendererBinding: 'VueRenderPreview', + control: { + popoverContent: 'Use a Rich Text Editor to add HTML-formatted', + order: 2.0, + group: 'Content Fields', + label: 'Vue component Editor', + component: 'VueRenderPreview', + 'editor-component': 'VueRenderEditor', + 'editor-control': 'VueRenderPreview', + config: { + icon: 'fas fa-pencil-ruler', + interactive: true, + templateVue: '

Vue component editor

', + handlerVue: 'return {}', + renderVarHtml: null, + ignoreMustache: true, + }, + inspector: [ + { + type: 'FormInput', + field: 'componentName', + config: { + label: 'Component Name', + helper: 'Name of the component', + }, + }, + { + type: EditorFieldInspector, + field: 'templateVue', + config: { + rows: 5, + label: 'Template Vue', + helper: 'The template vue component to display', + value: '

Vue component editor

', + language: 'html', + enablePopup: true, + openPopupLabel: 'Open in popup', + popupTitle: 'Edit template', + cancelPopupLabel: 'Cancel', + savePopupLabel: 'Save', + }, + }, + { + type: EditorFieldInspector, + field: 'handlerVue', + config: { + label: 'Handler Vue', + helper: 'The code vue component to display', + value: 'return {}', + language: 'javascript', + enablePopup: true, + openPopupLabel: 'Open in popup', + popupTitle: 'Edit script', + cancelPopupLabel: 'Cancel', + savePopupLabel: 'Save', + }, + }, + ], + }, +}; + export default [ { editorComponent: FormHtmlEditor, @@ -1292,5 +1358,6 @@ export default [ }, inspector: [], } - } + }, + VueComponentEditorConfig ]; diff --git a/src/stories/VueRender.stories.js b/src/stories/VueRender.stories.js new file mode 100644 index 000000000..569e4adfd --- /dev/null +++ b/src/stories/VueRender.stories.js @@ -0,0 +1,274 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, within, userEvent } from '@storybook/test'; +import '../bootstrap'; +import VueRenderTemplate from '../components/renderer/vueRender/VueRenderTemplate'; +import VueRenderEditor from '../components/renderer/vueRender/VueRenderEditor.vue'; +import VueRenderPreview from '../components/renderer/vueRender/VueRenderPreview.vue'; + +const defaultHandler = 'return {}'; + +export default { + title: 'Components/VueRender', + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +}; + +export const TemplateWithCustomMarkup = { + name: 'VueRenderTemplate / custom template', + render: () => ({ + components: { VueRenderTemplate }, + template: ` + + `, + data() { + return { + templateVue: '

Hello from templateVue

', + handlerVue: defaultHandler, + }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('vue-render-message')).toHaveTextContent('Hello from templateVue'); + }, +}; + +export const TemplateWithHandlerMethod = { + name: 'VueRenderTemplate / handlerVue methods', + render: () => ({ + components: { VueRenderTemplate }, + template: ` + + `, + data() { + return { + validationData: {}, + templateVue: ` +
+ + {{ result }} +
+ `, + handlerVue: ` + return { + data() { return { result: '' }; }, + methods: { + onAction() { this.result = 'done'; } + } + }; + `, + }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('vue-render-action')); + await expect(canvas.getByTestId('vue-render-result')).toHaveTextContent('done'); + }, +}; + +export const TemplateHandlerError = { + name: 'VueRenderTemplate / invalid handlerVue', + render: () => ({ + components: { VueRenderTemplate }, + template: ` + + `, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('alert')).toHaveTextContent(/plain object/i); + }, +}; + +export const TemplateFieldError = { + name: 'VueRenderTemplate / error prop', + render: () => ({ + components: { VueRenderTemplate }, + template: ` + + `, + data() { + return { handlerVue: defaultHandler }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('alert')).toHaveTextContent('Field error'); + }, +}; + +export const TemplateWithHelper = { + name: 'VueRenderTemplate / helper', + render: () => ({ + components: { VueRenderTemplate }, + template: ` + + `, + data() { + return { handlerVue: defaultHandler }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Help text')).toBeInTheDocument(); + }, +}; + +export const TemplateNamedFieldSync = { + name: 'VueRenderTemplate / named field data', + render: () => ({ + components: { VueRenderTemplate }, + template: ` + + `, + data() { + return { + value: 'a', + validationData: { myField: 'a' }, + templateVue: '
{{ data.myField }}
', + handlerVue: defaultHandler, + lastInput: null, + }; + }, + methods: { + onInput(next) { + this.lastInput = next; + }, + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('vue-render-field')).toHaveTextContent('a'); + }, +}; + +export const EditorDefault = { + name: 'VueRenderEditor / design canvas', + render: () => ({ + components: { VueRenderEditor }, + template: ` +
+ +
+ `, + data() { + return { + templateVue: '

Editor preview

', + handlerVue: defaultHandler, + validationData: { sample: 1 }, + }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const root = canvas.getByTestId('vue-render-editor-root'); + await expect(root.querySelector('.form-group.pointer-events-none')).toBeTruthy(); + await expect(canvas.getByTestId('vue-render-editor-content')).toHaveTextContent('Editor preview'); + }, +}; + +export const EditorWithValidationError = { + name: 'VueRenderEditor / error state', + render: () => ({ + components: { VueRenderEditor }, + template: ` + + `, + data() { + return { handlerVue: defaultHandler }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Invalid')).toBeInTheDocument(); + await expect(canvas.getByText('Hint')).toBeInTheDocument(); + await expect(canvasElement.querySelector('.is-invalid')).toBeTruthy(); + }, +}; + +export const PreviewDefault = { + name: 'VueRenderPreview / runtime', + render: () => ({ + components: { VueRenderPreview }, + template: ` + + `, + data() { + return { + templateVue: '

Preview content

', + handlerVue: defaultHandler, + validationData: { field1: 'value' }, + }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('vue-render-preview-content')).toHaveTextContent('Preview content'); + }, +}; + +export const PreviewWithError = { + name: 'VueRenderPreview / error', + render: () => ({ + components: { VueRenderPreview }, + template: ` + + `, + data() { + return { handlerVue: defaultHandler }; + }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Preview error')).toBeInTheDocument(); + await expect(canvas.getByText('Preview helper')).toBeInTheDocument(); + }, +};