From 203cac7125c187652ee995e10521d8a8d47eef7c Mon Sep 17 00:00:00 2001 From: James Lucas Date: Wed, 18 Oct 2023 15:46:23 +1100 Subject: [PATCH] fix: move custom control registration into its own class and initialise it per formBuilder instance. This ensures definitions set in one formBuilder do not interact with definitions in other formBuilder instances --- src/js/control.js | 2 +- src/js/control/custom.js | 110 ++----------------- src/js/controls.js | 24 +++-- src/js/customControls.js | 198 ++++++++++++++++++++++++++++++++++ src/js/form-builder.js | 1 + src/js/form-render.js | 10 +- src/js/helpers.js | 6 +- tests/control/custom.test.js | 199 ++++++++++++++++++++++++++++++++++- tests/form-builder.test.js | 1 - 9 files changed, 425 insertions(+), 126 deletions(-) create mode 100644 src/js/customControls.js diff --git a/src/js/control.js b/src/js/control.js index 029f7726e..618b9bee3 100644 --- a/src/js/control.js +++ b/src/js/control.js @@ -163,7 +163,7 @@ export default class control { /** * Retrieve the class for a specified control type * @param {String} type type of control we are looking up - * @param {String} subtype if specified we'll try to find + * @param {String} [subtype] if specified we'll try to find * a class mapped to this subtype. If none found, fall back to the type. * @return {Class} control subclass as defined in the call to register */ diff --git a/src/js/control/custom.js b/src/js/control/custom.js index ff9376ff0..e41a3da33 100644 --- a/src/js/control/custom.js +++ b/src/js/control/custom.js @@ -1,5 +1,4 @@ import control from '../control' -import mi18n from 'mi18n' /** * Support for custom controls @@ -7,105 +6,10 @@ import mi18n from 'mi18n' * @extends control */ export default class controlCustom extends control { - /** - * Override the register method to allow passing 'templates' configuration data - * @param {Object} templates an object/hash of template data as defined https://formbuilder.online/docs/formBuilder/options/templates/ - * @param {Array} fields - */ - static register(templates = {}, fields = []) { - controlCustom.customRegister = {} - - if (!controlCustom.def) { - controlCustom.def = { - icon: {}, - i18n: {}, - } - } - - // store the template data against a static property - controlCustom.templates = templates - - // prepare i18n locale definition - const locale = mi18n.locale - if (!controlCustom.def.i18n[locale]) { - controlCustom.def.i18n[locale] = {} - } - // register each defined template against this class - control.register(Object.keys(templates), controlCustom) - - // build the control label & icon definitions - for (const field of fields) { - let type = field.type - field.attrs = field.attrs || {} - if (!type) { - if (!field.attrs.type) { - this.error('Ignoring invalid custom field definition. Please specify a type property.') - continue - } - type = field.attrs.type - } - - // default icon & label lookup - let lookup = field.subtype || type - - // if there is no template defined for this type, check if we already have this type/subtype registered - if (!templates[type]) { - // check that this type is already registered - const controlClass = control.getClass(type, field.subtype) - if (!controlClass) { - this.error( - 'Error while registering custom field: ' + - type + - (field.subtype ? ':' + field.subtype : '') + - '. Unable to find any existing defined control or template for rendering.', - ) - continue - } - - // generate a random key & map the settings against it - lookup = field.datatype ? field.datatype : `${type}-${Math.floor(Math.random() * 9000 + 1000)}` - - controlCustom.customRegister[lookup] = jQuery.extend(field, { - type: type, - class: controlClass, - }) - } - - // map label & icon - controlCustom.def.i18n[locale][lookup] = field.label - controlCustom.def.icon[lookup] = field.icon - } - } - - /** - * Returns any custom fields that map to an existing type/subtype combination - * @param {string|false} type optional type of control we want to look up - * subtypes of. If not specified will return all types - * @return {Array} registered custom lookup keys - */ - static getRegistered(type = false) { - if (type) { - return control.getRegistered(type) - } - return Object.keys(controlCustom.customRegister) - } - - /** - * Retrieve the class for a specified control type - * @param {string} lookup - custom control lookup to check for - * @return {Class} control subclass as defined in the call to register - */ - static lookup(lookup) { - return controlCustom.customRegister[lookup] - } - - /** - * Class configuration - return the icons & label translations defined in register - * @return {object} definition object - */ - static get definition() { - return controlCustom.def + constructor(config, preview, template) { + super(config,preview) + this.template = template } /** @@ -113,10 +17,11 @@ export default class controlCustom extends control { * @return {{field: any, layout: any}} DOM Element to be injected into the form. */ build() { - let custom = controlCustom.templates[this.type] + let custom = this.template if (!custom) { - return this.error( - 'Invalid custom control type. Please ensure you have registered it correctly as a template option.', + /* istanbul ignore next */ + return control.error( + `Invalid custom control type '${this.type}'. Please ensure you have registered it correctly as a template option.`, ) } @@ -158,4 +63,3 @@ export default class controlCustom extends control { } } } -controlCustom.customRegister = {} diff --git a/src/js/controls.js b/src/js/controls.js index ddab0bf50..78bb8a554 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -1,6 +1,6 @@ import './control/index' import control from './control' -import controlCustom from './control/custom' +import customControls from './customControls' import { unique, hyphenCase, markup as m } from './utils' import { empty } from './dom' import fontConfig from '../fonts/config.json' @@ -20,8 +20,6 @@ export default class Controls { constructor(opts, d) { this.opts = opts this.dom = d.controls - this.custom = controlCustom - this.getClass = control.getClass this.getRegistered = control.getRegistered // ability for controls to have their own configuration / options // of the format control identifier (type, or type.subtype): {options} @@ -46,14 +44,11 @@ export default class Controls { // load in any custom specified controls, or preloaded plugin controls control.loadCustom(opts.controls) // register any passed custom templates & fields - if (Object.keys(opts.fields).length) { - controlCustom.register(opts.templates, opts.fields) - } + this.custom = new customControls(opts.templates, opts.fields) // retrieve a full list of loaded controls const registeredControls = control.getRegistered() - this.registeredControls = registeredControls - const customFields = controlCustom.getRegistered() + const customFields = this.custom.getRegistered() if (customFields) { jQuery.merge(registeredControls, customFields) } @@ -72,7 +67,7 @@ export default class Controls { for (let i = 0; i < registeredControls.length; i++) { const type = registeredControls[i] // first check if this is a custom control - let custom = controlCustom.lookup(type) + let custom = this.custom.lookup(type) let controlClass if (custom) { controlClass = custom.class @@ -186,4 +181,15 @@ export default class Controls { }) this.dom.appendChild(fragment) } + + /** + * Retrieve the class for a specified control type + * @param {String} type type of control we are looking up + * @param {String} [subtype] if specified we'll try to find + * a class mapped to this subtype. If none found, fall back to the type. + * @return {Class} control subclass as defined in the call to register + */ + getClass(type, subtype) { + return this.custom.getClass(type) || control.getClass(type, subtype) + } } diff --git a/src/js/customControls.js b/src/js/customControls.js new file mode 100644 index 000000000..a17563782 --- /dev/null +++ b/src/js/customControls.js @@ -0,0 +1,198 @@ +import mi18n from 'mi18n' +import control from './control' +import controlCustom from './control/custom' + +/** + * customControls serves as a register for two types of custom fields supported by formBuilder + * - Custom controls defined by a template + * - Custom control defined by a field definition only + * + * The code takes two paths + * - Custom controls with a template will be a proxy function created to generate a controlCustom class + * - Fields without templates will map to their defined type/subtype class + */ +export default class customControls { + constructor(templates = {}, fields = []) { + this.customRegister = {} + this.templateControlRegister = {} + this.def = { + icon: {}, + i18n: {}, + } + this.register(templates, fields) + } + + /** + * Override the register method to allow passing 'templates' configuration data + * @param {Object} templates an object/hash of template data as defined https://formbuilder.online/docs/formBuilder/options/templates/ + * @param {Array} fields + */ + register(templates = {}, fields = []) { + // prepare i18n locale definition + const locale = mi18n.locale + if (!this.def.i18n[locale]) { + this.def.i18n[locale] = {} + } + + const _this = this + Object.keys(templates).forEach(templateName => { + const templateControl = function(config, preview) { + this.customControl = new controlCustom(config, preview, templates[templateName]) + + /** + * build a custom control defined in the templates option + * @return {{field: any, layout: any}} DOM Element to be injected into the form. + */ + this.build = function() { + return this.customControl.build() + } + + this.on = function(eventType) { + return this.customControl.on(eventType) + } + } + templateControl.definition = {} + templateControl.label = type => _this.label(type) + this.templateControlRegister[templateName] = templateControl + }) + + // build the control label & icon definitions + for (const field of fields) { + let type = field.type + field.attrs = field.attrs || {} + if (!type) { + if (!field.attrs.type) { + control.error('Ignoring invalid custom field definition. Please specify a type property.') + continue + } + type = field.attrs.type + } + + // default icon & label lookup + let lookup = field.subtype || type + + // if there is no template defined for this type, check if we already have this type/subtype registered + if (!templates[type]) { + // check that this type is already registered + const controlClass = control.getClass(type, field.subtype) + if (!controlClass) { + super.error( + 'Error while registering custom field: ' + + type + + (field.subtype ? ':' + field.subtype : '') + + '. Unable to find any existing defined control or template for rendering.', + ) + continue + } + + // generate a random key & map the settings against it + lookup = field.datatype ? field.datatype : `${type}-${Math.floor(Math.random() * 9000 + 1000)}` + + this.customRegister[lookup] = jQuery.extend(field, { + type: type, + class: controlClass, + }) + } else { + //Map the field definition into the templated control class + const controlClass = this.templateControlRegister[type] + controlClass.definition = field + this.customRegister[lookup] = jQuery.extend(field, { + type: type, + class: controlClass, + }) + } + + // map label & icon + this.def.i18n[locale][lookup] = field.label + this.def.icon[lookup] = field.icon + } + } + + /** + * Retrieve the translated control label for a control type + * @param {String} type + * @return {String} translated control + */ + label(type) { + /** + * Retrieve a translated string + * By default looks for translations defined against the class (for plugin controls) + * Expects {locale1: {type: label}, locale2: {type: label}}, or {default: label}, or {local1: label, local2: label2} + * @param {String} lookup string to retrieve the label / translated string for + * @param {Object|Number|String} [args] - string or key/val pairs for string lookups with variables + * @return {String} the translated label + */ + const def = this.definition + let i18n = def.i18n || {} + const locale = mi18n.locale + i18n = i18n[locale] || i18n.default || i18n + const lookupCamel = control.camelCase(type) + + // if translation is defined in the control, return it + const value = typeof i18n == 'object' ? i18n[lookupCamel] || i18n[type] : i18n + if (value) { + return value + } + + // otherwise check the mi18n object - allow for mapping a lookup to a custom mi18n lookup + let mapped = def.mi18n + if (typeof mapped === 'object') { + mapped = mapped[lookupCamel] || mapped[type] + } + if (!mapped) { + mapped = lookupCamel + } + return mi18n.get(mapped) + } + + get definition() { + return {} + } + + /** + * Retrieve the icon for a control type + * @param {String} type + * @return {String} icon + */ + icon(type) { + // @todo - support for `${css_prefix_text}${attr.name}` - is this for inputSets? Doesnt look like it but can't see anything else that sets attr.name? + // https://formbuilder.online/docs/formBuilder/options/inputSets/ + const def = this.definition + if (def && typeof def.icon === 'object') { + return def.icon[type] + } + return def.icon + } + + /** + * Returns any custom fields that map to an existing type/subtype combination + * @param {string|false} type optional type of control we want to look up + * subtypes of. If not specified will return all types + * @return {Array|function} registered custom lookup keys + */ + getRegistered(type = false) { + if (type) { + return this.templateControlRegister[type] ?? undefined + } + return Object.keys(this.customRegister) + } + + /** + * Retrieve the class for a specified control type + * @param {String} type type of control we are looking up + * a class mapped to this subtype. If none found, fall back to the type. + * @return {Class} control subclass as defined in the call to register + */ + getClass(type) { + return this.templateControlRegister[type] ?? undefined + } + + /** + * Retrieve the class for a specified control type + * @param {string} lookup - custom control lookup to check for + * @return {Class} control subclass as defined in the call to register + */ + lookup(lookup) { + return this.customRegister[lookup] + } +} \ No newline at end of file diff --git a/src/js/form-builder.js b/src/js/form-builder.js index bd4641738..95b28bbbf 100644 --- a/src/js/form-builder.js +++ b/src/js/form-builder.js @@ -87,6 +87,7 @@ function FormBuilder(opts, element, $) { data.formID = formID data.lastID = `${data.formID}-fld-0` const controls = new Controls(opts, d) + formBuilder.controls = controls const subtypes = (config.subtypes = h.processSubtypes(opts.subtypes)) diff --git a/src/js/form-render.js b/src/js/form-render.js index 77851ed3c..86eb528b1 100644 --- a/src/js/form-render.js +++ b/src/js/form-render.js @@ -5,10 +5,10 @@ import events from './events' import layout from './layout' import control from './control' import './control/index' -import controlCustom from './control/custom' import { defaultI18n } from './config' import '../sass/form-render.scss' import { setSanitizerConfig } from './sanitizer' +import customControls from './customControls' /** * FormRender Class @@ -85,9 +85,7 @@ class FormRender { control.loadCustom(options.controls) // register any passed custom templates - if (Object.keys(this.options.templates).length) { - controlCustom.register(this.options.templates) - } + this.templatedControls = new customControls(this.options.templates) /** * Extend Element prototype to allow us to append fields @@ -207,7 +205,7 @@ class FormRender { const sanitizedField = this.sanitizeField(fieldData, instanceIndex) // determine the control class for this type, and then process it through the layout engine - const controlClass = control.getClass(fieldData.type, fieldData.subtype) + const controlClass = this.templatedControls.getClass(fieldData.type) || control.getClass(fieldData.type, fieldData.subtype) const field = engine.build(controlClass, sanitizedField) rendered.push(field) @@ -268,7 +266,7 @@ class FormRender { // determine the control class for this type, and then build it const engine = new opts.layout() - const controlClass = control.getClass(fieldData.type, fieldData.subtype) + const controlClass = this.templatedControls.getClass(fieldData.type) || control.getClass(fieldData.type, fieldData.subtype) const forceTemplate = opts.forceTemplate || 'hidden' // support the ability to override what layout template the control is rendered using. This can be used to output the whole row (including label, help etc) using the standard templates if desired. const field = engine.build(controlClass, sanitizedField, forceTemplate) element.appendFormFields(field) diff --git a/src/js/helpers.js b/src/js/helpers.js index bf905c75f..3feabcf4d 100644 --- a/src/js/helpers.js +++ b/src/js/helpers.js @@ -20,7 +20,6 @@ import { import events from './events' import { config, defaultTimeout, styles } from './config' import control from './control' -import controlCustom from './control/custom' import storageAvailable from 'storage-available' /** @@ -422,8 +421,9 @@ export default class Helpers { $field.data('fieldData', previewData) // determine the control class for this type, and then process it through the layout engine - const custom = controlCustom.lookup(previewData.type) - const controlClass = custom ? custom.class : control.getClass(previewData.type, previewData.subtype) + const custom = _this.formBuilder.controls.custom.lookup(previewData.type) + const template = _this.formBuilder.controls.custom.getClass(previewData.type) + const controlClass = custom ? custom.class : template || control.getClass(previewData.type, previewData.subtype) const preview = this.layout.build(controlClass, previewData) empty($prevHolder[0]) diff --git a/tests/control/custom.test.js b/tests/control/custom.test.js index 3a487f7d2..8c8caef7a 100644 --- a/tests/control/custom.test.js +++ b/tests/control/custom.test.js @@ -1,8 +1,9 @@ require('../setup-fb') require('./../../src/js/form-builder.js') +require('./../../src/js/form-render.js') describe('Test Custom Control', () => { - test('test building custom control element', async () => { + test('test add custom field with template', async () => { const fbWrap = $('
') const cbOnRender = jest.fn() @@ -16,7 +17,7 @@ describe('Test Custom Control', () => { const templates = { starRating: function(fieldData) { return { - field: '', + field: this.markup('span', null, { name: fieldData.name}), onRender: cbOnRender } } @@ -25,10 +26,202 @@ describe('Test Custom Control', () => { const fb = await $(fbWrap).formBuilder({fields, templates}).promise const field = { type: 'starRating', - class: 'form-control' + className: 'form-control' } fb.actions.addField(field) expect(cbOnRender.mock.calls).toHaveLength(1) + + $(fbWrap).find('li.input-control[data-type="starRating"]').click() + + expect(cbOnRender.mock.calls).toHaveLength(2) + }) + + test('test rendering custom field with template', async () => { + const fbWrap = $('
') + const cbOnRender = jest.fn() + + const formData = [ + { + 'type': 'starRating', + 'required': false, + 'label': 'Star Rating', + 'name': 'starRating-1697591966052-0' + }, + ] + const templates = { + starRating: function(fieldData) { + return { + field: '', + onRender: cbOnRender + } + } + } + + const fr = await $(fbWrap).formRender({formData, templates}).promise + + expect(cbOnRender.mock.calls).toHaveLength(1) + + expect($(fbWrap).find('#starRating-1697591966052-0')[0].outerHTML).toBe('') + + }) + + test('override built-in with template', async () => { + const fbWrap = $('
') + const cbOnRender = jest.fn() + + const fields = [ + { + type: 'checkbox-group', + subtype: 'custom-group', + label: 'Custom Checkbox Group w/Sub Type', + required: !0, + values: [{ + label: 'Option 1' + }, { + label: 'Option 2' + }] + } + ] + const templates = { + text: function(fieldData) { + return { + field: $('')[0], + onRender: cbOnRender + } + } + } + + const fb = await $(fbWrap).formBuilder({fields, templates}).promise + const field = { + type: 'text', + className: 'form-control' + } + fb.actions.addField(field) + + expect(cbOnRender.mock.calls).toHaveLength(1) + + $(fbWrap).find('li.input-control[data-type="text"]').click() + + expect(cbOnRender.mock.calls).toHaveLength(2) + }) + + test('can set field attributes from custom field config', async () => { + const fbWrap = $('
') + const fields = [ + { + className: 'form-control custom-class', + label: 'Custom Text Field', + type: 'customText', + value: 'String to look for', + icon: '🔢' + }, + ] + const templates = { + customText: function(fieldData) { + let { name } = fieldData + name = fieldData.multiple ? `${name}[]` : name + const inputConfig = Object.assign({}, fieldData, { name }) + this.dom = this.markup('input', null, inputConfig) + + return { + field: this.dom, + onRender: function () { + if (fieldData.userData) { + $(this.dom).val(fieldData.userData[0]) + } + } + } + }, + } + + const fb = await $(fbWrap).formBuilder({fields, templates, typeUserAttrs: { customText: { dataAttr: { value: '', label: 'textDataAttr'} } }}).promise + const field = { + type: 'customText', + className: 'form-control api-class', + value: 'Added by API', + } + fb.actions.addField(field) + + let renderedCtl = $(fbWrap).find('.prev-holder input') + expect(renderedCtl.attr('class')).toBe('form-control api-class') + expect(renderedCtl.attr('value')).toBe('Added by API') + expect(renderedCtl.attr('id')).toMatch(new RegExp('^customText-.*')) + + fb.actions.clearFields() + + $(fbWrap).find('li.input-control[data-type="customText"]').click() + + renderedCtl = $(fbWrap).find('.prev-holder input') + expect(renderedCtl.attr('class')).toBe('form-control custom-class') + expect(renderedCtl.attr('value')).toBe('String to look for') + expect(renderedCtl.attr('id')).toMatch(new RegExp('^customText-.*')) + }) + + test('can add custom fields from input-set', async () => { + const fbWrap = $('
') + const fields = [ + { + className: 'form-control custom-class', + label: 'Custom Text Field', + type: 'customText', + value: 'String to look for', + icon: '🔢' + }, + ] + const templates = { + customText: function(fieldData) { + let { name } = fieldData + name = fieldData.multiple ? `${name}[]` : name + const inputConfig = Object.assign({}, fieldData, { name }) + this.dom = this.markup('input', null, inputConfig) + + return { + field: this.dom, + onRender: function () { + if (fieldData.userData) { + $(this.dom).val(fieldData.userData[0]) + } + } + } + }, + } + const inputSets = [ + { + label: 'My Input Set', + name: 'test-input-set', + icon: '🔢', + fields: [ + { + className: 'form-control custom-class', + label: 'My Custom Text', + type: 'customText', + value: 'String to look for', + icon: '🔢', + }, + { + className: 'form-control', + label: 'My Custom Text', + type: 'text', + }, + ] + }, + + ] + + const fb = await $(fbWrap).formBuilder({fields, templates, inputSets}).promise + + $(fbWrap).find('li.input-set-control[data-type="test-input-set"]').click() + + const renderedCtl = $(fbWrap).find('.prev-holder input') + expect(renderedCtl.eq(0).attr('class')).toBe('form-control custom-class') + expect(renderedCtl.eq(0).attr('value')).toBe('String to look for') + expect(renderedCtl.eq(0).attr('type')).toBe('customText') + expect(renderedCtl.eq(0).attr('id')).toMatch(new RegExp('^customText-.*')) + + expect(renderedCtl.eq(1).attr('class')).toBe('form-control') + expect(renderedCtl.eq(1).attr('value')).toBeUndefined() + expect(renderedCtl.eq(1).attr('type')).toBe('text') + expect(renderedCtl.eq(1).attr('id')).toMatch(new RegExp('^text-.*')) }) }) \ No newline at end of file diff --git a/tests/form-builder.test.js b/tests/form-builder.test.js index b50ab5a71..b9785271b 100644 --- a/tests/form-builder.test.js +++ b/tests/form-builder.test.js @@ -326,7 +326,6 @@ describe('FormBuilder typeUserAttrs detection', () => { fb.actions.addField({ type: 'button'}) input = fbWrap.find('.button-field .testAttribute-wrap input') - console.log(input) expect(input.attr('type')).toBe('text') expect(input.val()).toBe('buttonOverride')