src/formio.form.builder.js
import FormioForm from './formio.form';
import dragula from 'dragula';
import Components from './components/builder';
import {FormioComponents} from './components/Components';
import { BuilderUtils } from './utils/builder';
import FormioUtils from './utils';
import EventEmitter from 'eventemitter2';
import Promise from 'native-promise-only';
import _ from 'lodash';
export class FormioFormBuilder extends FormioForm {
constructor(element, options) {
super(element, options);
let self = this;
this.dragContainers = [];
this.sidebarContainers = [];
this.updateDraggable = _.debounce(this.refreshDraggable.bind(this), 200);
// Setup the builder options.
this.options.builder = _.defaultsDeep({}, this.options.builder, {
basic: {
title: 'Basic Components',
weight: 0,
default: true,
},
advanced: {
title: 'Advanced',
weight: 10
},
layout: {
title: 'Layout',
weight: 20
},
data: {
title: 'Data',
weight: 30
}
});
this.builderReady = new Promise((resolve) => {
this.builderReadyResolve = resolve;
});
this.groups = {};
this.options.sideBarScroll = _.get(this.options, 'sideBarScroll', true);
this.options.sideBarScrollOffset = _.get(this.options, 'sideBarScrollOffset', 0);
this.options.hooks = this.options.hooks || {};
this.options.hooks.addComponents = function(components) {
if (!components || (!components.length && !components.nodrop)) {
// Return a simple alert so they know they can add something here.
return [
{
type: 'htmlelement',
internal: true,
tag: 'div',
className: 'alert alert-info',
attrs: [
{attr: 'id', value: this.id + '-placeholder'},
{attr: 'style', value: 'text-align:center; margin-bottom: 0px;'},
{attr: 'role', value: 'alert'}
],
content: 'Drag and Drop a form component'
}
];
}
return components;
};
this.options.hooks.addComponent = function(container, comp) {
if (!comp || !comp.component) {
return container;
}
if (!comp.noEdit && !comp.component.internal) {
// Make sure the component position is relative so the buttons align properly.
comp.getElement().style.position = 'relative';
let removeButton = this.ce('div', {
class: 'btn btn-xxs btn-danger component-settings-button component-settings-button-remove'
}, this.ce('span', {class: 'glyphicon glyphicon-remove'}));
this.addEventListener(removeButton, 'click', () => self.deleteComponent(comp));
let editButton = this.ce('div', {
class: 'btn btn-xxs btn-default component-settings-button component-settings-button-edit'
}, this.ce('span', {class: 'glyphicon glyphicon-cog'}));
this.addEventListener(editButton, 'click', () => self.editComponent(comp));
// Add the edit buttons to the component.
comp.prepend(this.ce('div', {
class: 'component-btn-group'
}, [removeButton, editButton]));
}
if (!container.noDrop) {
self.addDragContainer(container, this);
}
return container;
};
this.setBuilderElement();
}
scrollSidebar() {
const newTop = (window.scrollY - this.sideBarTop) + this.options.sideBarScrollOffset;
const shouldScroll = (newTop > 0);
if (shouldScroll && ((newTop + this.sideBarElement.offsetHeight) < this.element.offsetHeight)) {
this.sideBarElement.style.marginTop = `${newTop}px`;
}
else if (shouldScroll && (this.sideBarElement.offsetHeight < this.element.offsetHeight)) {
this.sideBarElement.style.marginTop = `${this.element.offsetHeight - this.sideBarElement.offsetHeight}px`;
}
else {
this.sideBarElement.style.marginTop = '0px';
}
}
setBuilderElement() {
return this.onElement.then(() => {
this.addClass(this.wrapper, 'row formbuilder');
this.builderSidebar = this.ce('div', {
class: 'col-xs-4 col-sm-3 col-md-2 formcomponents'
});
this.prependTo(this.builderSidebar, this.wrapper);
this.addClass(this.element, 'col-xs-8 col-sm-9 col-md-10 formarea');
this.element.component = this;
this.buildSidebar();
this.sideBarTop = this.sideBarElement.getBoundingClientRect().top + window.scrollY;
if (this.options.sideBarScroll) {
this.addEventListener(window, 'scroll', _.throttle(this.scrollSidebar.bind(this), 10));
}
});
}
get ready() {
return this.builderReady;
}
deleteComponent(component) {
if (!component.parent) {
return;
}
let remove = true;
if (component.type === 'components' && component.getComponents().length > 0) {
remove = window.confirm(this.t('Removing this component will also remove all of its children. Are you sure you want to do this?'));
}
if (remove) {
this.emit('deleteComponent', component);
component.parent.removeComponentById(component.id);
this.form = this.schema;
}
return remove;
}
updateComponent(component) {
// Update the preview.
if (this.componentPreview) {
this.preview = Components.create(component.component, {
preview: true,
events: new EventEmitter({
wildcard: false,
maxListeners: 0
})
}, {}, true);
this.preview.on('componentEdit', (comp) => {
_.merge(component.component, comp.component);
this.editForm.redraw();
});
this.preview.build();
this.preview.isBuilt = true;
this.componentPreview.innerHTML = '';
this.componentPreview.appendChild(this.preview.getElement());
}
// Ensure this component has a key.
if (component.isNew) {
if (!component.keyModified) {
component.component.key = _.camelCase(
component.component.label ||
component.component.placeholder ||
component.component.type
);
}
// Set a unique key for this component.
BuilderUtils.uniquify(this._form, component.component);
}
// Change the "default value" field to be reflective of this component.
if (this.defaultValueComponent) {
_.assign(this.defaultValueComponent, _.omit(component.component, [
'key',
'label',
'placeholder',
'tooltip',
'validate'
]));
}
// Called when we update a component.
this.emit('updateComponent', component);
}
editComponent(component) {
let componentCopy = _.cloneDeep(component);
let componentClass = Components[componentCopy.component.type];
// Make sure we only have one dialog open at a time.
if (this.dialog) {
this.dialog.close();
}
this.dialog = this.createModal(componentCopy.name);
let formioForm = this.ce('div');
this.componentPreview = this.ce('div', {
class: 'component-preview'
});
let componentInfo = componentClass ? componentClass.builderInfo : {};
let saveButton = this.ce('button', {
class: 'btn btn-success',
style: 'margin-right: 10px;'
}, this.t('Save'));
let cancelButton = this.ce('button', {
class: 'btn btn-default',
style: 'margin-right: 10px;'
}, this.t('Cancel'));
let removeButton = this.ce('button', {
class: 'btn btn-danger'
}, this.t('Remove'));
let componentEdit = this.ce('div', {}, [
this.ce('div', {
class: 'row'
}, [
this.ce('div', {
class: 'col col-sm-6'
}, this.ce('p', {
class: 'lead'
}, componentInfo.title + ' Component')),
this.ce('div', {
class: 'col col-sm-6'
}, [
this.ce('div', {
class: 'pull-right',
style: 'margin-right: 20px; margin-top: 10px'
}, this.ce('a', {
href: componentInfo.documentation || '#',
target: '_blank'
}, this.ce('i', {
class: 'glyphicon glyphicon-new-window'
}, ' ' + this.t('Help'))))
])
]),
this.ce('div', {
class: 'row'
}, [
this.ce('div', {
class: 'col col-sm-6'
}, formioForm),
this.ce('div', {
class: 'col col-sm-6'
}, [
this.ce('div', {
class: 'panel panel-default preview-panel'
}, [
this.ce('div', {
class: 'panel-heading'
}, this.ce('h3', {
class: 'panel-title'
}, this.t('Preview'))),
this.ce('div', {
class: 'panel-body'
}, this.componentPreview)
]),
this.ce('div', {
style: 'margin-top: 10px;'
}, [
saveButton,
cancelButton,
removeButton
])
])
])
]);
// Append the settings page to the dialog body.
this.dialog.body.appendChild(componentEdit);
const editForm = Components[componentCopy.component.type].editForm();
// Change the defaultValue component to be reflective.
this.defaultValueComponent = FormioUtils.getComponent(editForm.components, 'defaultValue');
_.assign(this.defaultValueComponent, _.omit(componentCopy.component, [
'key',
'label',
'placeholder',
'tooltip',
'validate'
]));
// Create the form instance.
this.editForm = new FormioForm(formioForm);
// Set the form to the edit form.
this.editForm.form = editForm;
// Pass along the form being edited.
this.editForm.editForm = this._form;
// Update the preview with this component.
this.updateComponent(componentCopy);
// Register for when the edit form changes.
this.editForm.on('change', (event) => {
if (event.changed) {
// See if this is a manually modified key.
if (event.changed.component && (event.changed.component.key === 'key')) {
componentCopy.keyModified = true;
}
// Set the component JSON to the new data.
componentCopy.component = event.data;
// Update the component.
this.updateComponent(componentCopy);
}
});
// Modify the component information in the edit form.
this.editForm.formReady.then(() => this.editForm.setValue({data: componentCopy.component}, {
noUpdateEvent: true
}));
this.addEventListener(cancelButton, 'click', (event) => {
event.preventDefault();
this.emit('cancelComponent', component);
this.dialog.close();
});
this.addEventListener(removeButton, 'click', (event) => {
event.preventDefault();
this.deleteComponent(component);
this.dialog.close();
});
this.addEventListener(saveButton, 'click', (event) => {
event.preventDefault();
component.isNew = false;
component.component = componentCopy.component;
if (component.dragEvents && component.dragEvents.onSave) {
component.dragEvents.onSave(component);
}
this.emit('saveComponent', component);
this.form = this.schema;
this.dialog.close();
});
this.addEventListener(this.dialog, 'close', () => {
this.editForm.destroy();
if (component.isNew) {
this.deleteComponent(component);
}
});
// Called when we edit a component.
this.emit('editComponent', component);
}
destroy() {
super.destroy();
if (this.dragula) {
this.dragula.destroy();
}
}
/**
* Insert an element in the weight order.
*
* @param info
* @param items
* @param element
* @param container
*/
insertInOrder(info, items, element, container) {
// Determine where this item should be added.
let beforeWeight = 0;
let before = null;
_.each(items, (itemInfo) => {
if (
(info.key !== itemInfo.key) &&
(info.weight < itemInfo.weight) &&
(!beforeWeight || (itemInfo.weight < beforeWeight))
) {
before = itemInfo.element;
beforeWeight = itemInfo.weight;
}
});
if (before) {
try {
container.insertBefore(element, before);
}
catch (err) {
container.appendChild(element);
}
}
else {
container.appendChild(element);
}
}
addBuilderGroup(info, container) {
if (!info || !info.key) {
console.warn('Invalid Group Provided.');
return;
}
info = _.clone(info);
let groupAnchor = this.ce('a', {
href: `#group-${info.key}`
}, this.text(info.title));
// Add a listener when it is clicked.
this.addEventListener(groupAnchor, 'click', (event) => {
event.preventDefault();
let clickedGroupId = event.target.getAttribute('href').replace('#group-', '');
if (this.groups[clickedGroupId]) {
let clickedGroup = this.groups[clickedGroupId];
let wasIn = this.hasClass(clickedGroup.panel, 'in');
_.each(this.groups, (group, groupId) => {
this.removeClass(group.panel, 'in');
if ((groupId === clickedGroupId) && !wasIn) {
this.addClass(group.panel, 'in');
let parent = group.parent;
while (parent) {
this.addClass(parent.panel, 'in');
parent = parent.parent;
}
}
});
// Match the form builder height to the sidebar.
this.element.style.minHeight = this.builderSidebar.offsetHeight + 'px';
this.scrollSidebar();
}
});
info.element = this.ce('div', {
class: 'panel panel-default form-builder-panel',
id: `group-panel-${info.key}`
}, [
this.ce('div', {
class: 'panel-heading'
}, [
this.ce('h4', {
class: 'panel-title'
}, groupAnchor)
])
]);
info.body = this.ce('div', {
class: 'panel-body no-drop'
});
// Add this group body to the drag containers.
this.sidebarContainers.push(info.body);
let groupBodyClass = 'panel-collapse collapse';
if (info.default) {
groupBodyClass += ' in';
}
info.panel = this.ce('div', {
class: groupBodyClass,
id: `group-${info.key}`
}, info.body);
info.element.appendChild(info.panel);
this.groups[info.key] = info;
this.insertInOrder(info, this.groups, info.element, container);
// Now see if this group has subgroups.
if (info.groups) {
_.each(info.groups, (subInfo, subGroup) => {
subInfo.key = subGroup;
subInfo.parent = info;
this.addBuilderGroup(subInfo, info.body);
});
}
}
addBuilderComponentInfo(component) {
if (!component || !component.group || !this.groups[component.group]) {
return;
}
component = _.clone(component);
let groupInfo = this.groups[component.group];
if (!groupInfo.components) {
groupInfo.components = {};
}
if (!groupInfo.components.hasOwnProperty(component.key)) {
groupInfo.components[component.key] = component;
}
return component;
}
addBuilderComponent(component, group) {
if (!component) {
return;
}
if (!group && component.group && this.groups[component.group]) {
group = this.groups[component.group];
}
if (!group) {
return;
}
component.element = this.ce('span', {
id: `builder-${component.key}`,
class: 'btn btn-primary btn-xs btn-block formcomponent drag-copy'
});
if (component.icon) {
component.element.appendChild(this.ce('i', {
class: component.icon,
style: 'margin-right: 5px;'
}));
}
component.element.builderInfo = component;
component.element.appendChild(this.text(component.title));
this.insertInOrder(component, group.components, component.element, group.body);
return component;
}
buildSidebar() {
this.groups = {};
this.sidebarContainers = [];
if (this.sideBarElement) {
this.removeChildFrom(this.sideBarElement, this.builderSidebar);
}
this.sideBarElement = this.ce('div', {
class: 'panel-group'
});
// Add the groups.
_.each(this.options.builder, (info, group) => {
if (info) {
info.key = group;
this.addBuilderGroup(info, this.sideBarElement);
}
});
// Get all of the components builder info grouped and sorted.
let components = {};
let allComponents = _.filter(_.map(_.assign(Components, FormioComponents.customComponents), (component, type) => {
if (!component.builderInfo) {
return null;
}
component.type = type;
return component;
}));
_.map(_.sortBy(allComponents, component => {
return component.builderInfo.weight;
}), (component) => {
let builderInfo = component.builderInfo;
builderInfo.key = component.type;
components[builderInfo.key] = builderInfo;
this.addBuilderComponentInfo(builderInfo);
});
// Add the components in each group.
_.each(this.groups, (info) =>
_.each(info.components, (comp, key) => {
if (comp) {
this.addBuilderComponent(comp === true ? components[key] : comp, info);
}
})
);
// Add the new sidebar element.
this.builderSidebar.appendChild(this.sideBarElement);
this.updateDraggable();
}
getParentElement(element) {
let containerComponent = element;
do { containerComponent = containerComponent.parentNode } while (containerComponent && !containerComponent.component);
return containerComponent;
}
addDragContainer(element, component, dragEvents) {
_.remove(this.dragContainers, (container) => (element.id && (element.id === container.id)));
element.component = component;
if (dragEvents) {
element.dragEvents = dragEvents;
}
this.addClass(element, 'drag-container');
if (!element.id) {
element.id = `builder-element-${component.id}`;
}
this.dragContainers.push(element);
this.updateDraggable();
}
clear() {
super.clear();
this.dragContainers = [];
}
addComponentTo(parent, schema, element, sibling) {
return parent.addComponent(
schema,
element,
parent.data,
sibling
);
}
onDrop(element, target, source, sibling) {
let builderElement = source.querySelector('#' + element.id);
let newParent = this.getParentElement(element);
if (!newParent || !newParent.component) {
return console.warn('Could not find parent component.');
}
// Remove any instances of the placeholder.
let placeholder = document.getElementById(newParent.component.id + '-placeholder');
if (placeholder) {
placeholder.parentNode.removeChild(placeholder);
}
// If the sibling is the placeholder, then set it to null.
if (sibling === placeholder) {
sibling = null;
}
// Make this element go before the submit button if it is still on the builder.
if (!sibling && this.submitButton && newParent.contains(this.submitButton.element)) {
sibling = this.submitButton.element;
}
// If this is a new component, it will come from the builderElement
if (
builderElement &&
builderElement.builderInfo &&
builderElement.builderInfo.schema
) {
let componentSchema = _.clone(builderElement.builderInfo.schema);
if (target.dragEvents && target.dragEvents.onDrop) {
target.dragEvents.onDrop(element, target, source, sibling, componentSchema);
}
// Add the new component.
let component = this.addComponentTo(newParent.component, componentSchema, newParent, sibling);
// Set that this is a new component.
component.isNew = true;
// Pass along the save event.
if (target.dragEvents) {
component.dragEvents = target.dragEvents;
}
// Edit the component.
this.editComponent(component);
// Remove the element.
target.removeChild(element);
}
// Check to see if this is a moved component.
else if (element.component) {
let componentSchema = element.component.schema;
if (target.dragEvents && target.dragEvents.onDrop) {
target.dragEvents.onDrop(element, target, source, sibling, componentSchema);
}
// Remove the component from its parent.
if (element.component.parent) {
element.component.parent.removeComponent(element.component);
}
// Add the component to its new parent.
let component = newParent.component.addComponent(
componentSchema,
newParent,
newParent.component.data,
sibling
);
if (target.dragEvents && target.dragEvents.onSave) {
target.dragEvents.onSave(component);
}
// Refresh the form.
this.form = this.schema;
}
}
/**
* Adds a submit button if there are no components.
*/
addSubmitButton() {
if (!this.getComponents().length) {
this.submitButton = this.addComponent({
type: 'button',
label: 'Submit',
key: 'submit',
size: 'md',
block: false,
action: 'submit',
disableOnInvalid: true,
theme: 'primary'
});
}
}
refreshDraggable() {
if (this.dragula) {
this.dragula.destroy();
}
this.dragula = dragula(this.sidebarContainers.concat(this.dragContainers), {
copy: function(el, source) {
return el.classList.contains('drag-copy');
},
accepts: function(el, target) {
return !target.classList.contains('no-drop');
}
}).on('drop', (element, target, source, sibling) => this.onDrop(element, target, source, sibling));
// If there are no components, then we need to add a default submit button.
this.addSubmitButton();
this.builderReadyResolve();
}
build() {
super.build();
this.updateDraggable();
this.formReadyResolve();
}
}