diff --git a/src/components/index.js b/src/components/index.js index e001acd7..641ed513 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 00000000..657c45d2 --- /dev/null +++ b/src/components/inspector/vueRender/EditorFieldInspector.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/src/components/renderer/vueRender/VueRenderEditor.vue b/src/components/renderer/vueRender/VueRenderEditor.vue new file mode 100644 index 00000000..6089343c --- /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 00000000..399bf186 --- /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 00000000..1256816f --- /dev/null +++ b/src/components/renderer/vueRender/VueRenderTemplate.js @@ -0,0 +1,250 @@ +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 + || null; + }, + 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 authored in the screen designer + const evaluated = new Function(this.handlerVue)(); + 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 self = this; + const templateSource = this.resolveTemplateSource(); + + let component; + try { + component = createElement({ + components: { + ...(this.components || {}), + ...handlerOptions.components, + }, + template: templateSource, + data() { + const base = { + data: self.resolveTransientData() || {}, + }; + 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() { + return self.resolveTransientData() || {}; + }, + set(value) { + const target = self.resolveTransientData(); + if (target && typeof target === 'object') { + Object.assign(target, value); + } + }, + }, + }, + watch: { + ...handlerOptions.watch, + data(value) { + const target = self.resolveTransientData(); + if (!target || !value || typeof value !== 'object' || value === target) { + return; + } + Object.assign(target, value); + this.data = target; + }, + }, + methods: { + ...handlerOptions.methods, + submit() { + self.$emit('submit'); + }, + getData() { + return self.resolveTransientData() || {}; + }, + 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 a8d21216..b4857145 100755 --- a/src/form-builder-controls.js +++ b/src/form-builder-controls.js @@ -29,6 +29,9 @@ 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 VueRenderEditor from './components/renderer/vueRender/VueRenderEditor.vue'; +import VueRenderPreview from './components/renderer/vueRender/VueRenderPreview.vue'; +import EditorFieldInspector from './components/inspector/vueRender/EditorFieldInspector.vue'; import { bgcolorProperty, @@ -56,11 +59,76 @@ 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, + editorComponent: 'FormHtmlEditor', editorBinding: 'FormHtmlEditor', - rendererComponent: FormHtmlEditor, + rendererComponent: 'FormHtmlEditor', rendererBinding: 'FormHtmlEditor', control: { popoverContent: "Use a Rich Text Editor to add HTML-formatted", @@ -1292,5 +1360,6 @@ export default [ }, inspector: [], } - } + }, + VueComponentEditorConfig ];