import {FormLayoutNode} from 'app/client/components/FormRenderer'; import {buildEditor} from 'app/client/components/Forms/Editor'; import {FieldModel} from 'app/client/components/Forms/Field'; import {buildMenu} from 'app/client/components/Forms/Menu'; import {BoxModel} from 'app/client/components/Forms/Model'; import * as style from 'app/client/components/Forms/styles'; import {makeTestId} from 'app/client/lib/domUtils'; import {makeT} from 'app/client/lib/localization'; import {icon} from 'app/client/ui2018/icons'; import * as menus from 'app/client/ui2018/menus'; import {inlineStyle, not} from 'app/common/gutil'; import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs'; import {v4 as uuidv4} from 'uuid'; const testId = makeTestId('test-forms-'); const t = makeT('FormView'); export class ColumnsModel extends BoxModel { private _columnCount = Computed.create(this, use => use(this.children).length); public removeChild(box: BoxModel) { if (box.type === 'Placeholder') { // Make sure we have at least one rendered. if (this.children.get().length <= 1) { return; } return super.removeChild(box); } // We will replace this box with a placeholder. this.replace(box, Placeholder()); } // Dropping a box on this component (Columns) directly will add it as a new column. public accept(dropped: FormLayoutNode): BoxModel { if (!this.parent) { throw new Error('No parent'); } // We need to remove it from the parent, so find it first. const droppedRef = dropped.id ? this.root().find(dropped.id) : null; // If this is already my child, don't do anything. if (droppedRef && droppedRef.parent === this) { return droppedRef; } droppedRef?.removeSelf(); return this.append(dropped); } public render(...args: IDomArgs): HTMLElement { const dragHover = Observable.create(null, false); const content: HTMLElement = style.cssColumns( dom.autoDispose(dragHover), // Pass column count as a css variable (to style the grid). inlineStyle(`--css-columns-count`, this._columnCount), // Render placeholders as children. dom.forEach(this.children, (child) => { const toRender = child ?? BoxModel.new(Placeholder(), this); return toRender.render(testId('column')); }), // Append + button at the end. cssPlaceholder( testId('add'), icon('Plus'), dom.on('click', async () => { await this.save(() => { this.placeAfterListChild()(Placeholder()); }); }), style.cssColumn.cls('-add-button'), style.cssColumn.cls('-drag-over', dragHover), dom.on('dragleave', (ev) => { ev.stopPropagation(); ev.preventDefault(); // Just remove the style and stop propagation. dragHover.set(false); }), dom.on('dragover', (ev) => { // As usual, prevent propagation. ev.stopPropagation(); ev.preventDefault(); // Here we just change the style of the element. ev.dataTransfer!.dropEffect = "move"; dragHover.set(true); }), ), ...args, ); return buildEditor({ box: this, content }); } public async deleteSelf(): Promise { // Prepare all the fields that are children of this column for removal. const fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]); const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get()); await this.parent?.save(async () => { // FormView is particularly sensitive to the order that view fields and // the form layout are modified. Specifically, if the layout is // modified before view fields are removed, deleting a column with // mapped fields inside seems to break. The same issue affects sections // containing mapped fields. Reversing the order causes no such issues. // // TODO: narrow down why this happens and see if it's worth fixing. if (fieldIdsToRemove.length > 0) { await this.view.viewSection.removeField(fieldIdsToRemove); } // Remove each child of this column from the layout. this.children.get().forEach(child => { child.removeSelf(); }); // Remove this column from the layout. this.removeSelf(); }); } } export class PlaceholderModel extends BoxModel { public render(...args: IDomArgs): HTMLElement { const [box, view] = [this, this.view]; const scope = new MultiHolder(); const liveIndex = Computed.create(scope, (use) => { if (!box.parent) { return -1; } const parentChildren = use(box.parent.children); return parentChildren.indexOf(box); }); const boxModelAt = Computed.create(scope, (use) => { const index = use(liveIndex); if (index === null) { return null; } const childBox = use(box.children)[index]; if (!childBox) { return null; } return childBox; }); const dragHover = Observable.create(scope, false); return cssPlaceholder( style.cssDrop(), testId('Placeholder'), testId('element'), dom.attr('data-box-model', String(box.type)), dom.autoDispose(scope), style.cssColumn.cls('-drag-over', dragHover), style.cssColumn.cls('-empty', not(boxModelAt)), style.cssColumn.cls('-selected', use => use(view.selectedBox) === box), buildMenu({ box: this, insertBox, customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), t('Remove Column'))], }), dom.on('contextmenu', (ev) => { ev.stopPropagation(); }), dom.on('dragleave', (ev) => { ev.stopPropagation(); ev.preventDefault(); // Just remove the style and stop propagation. dragHover.set(false); }), dom.on('dragover', (ev) => { // As usual, prevent propagation. ev.stopPropagation(); ev.preventDefault(); // Here we just change the style of the element. ev.dataTransfer!.dropEffect = "move"; dragHover.set(true); }), dom.on('drop', (ev) => { ev.stopPropagation(); ev.preventDefault(); dragHover.set(false); // Get the box that was dropped. const dropped = JSON.parse(ev.dataTransfer!.getData('text/plain')); // We need to remove it from the parent, so find it first. const droppedId = dropped.id; const droppedRef = box.root().find(droppedId); // Make sure that the dropped stuff is not our parent. if (droppedRef) { for(const child of droppedRef.traverse()) { if (this === child) { return; } } } // Now we simply insert it after this box. bundleChanges(() => { droppedRef?.removeSelf(); const parent = box.parent!; parent.replace(box, dropped); parent.save().catch(reportError); }); }), // If we an occupant, render it. dom.maybe(boxModelAt, (child) => child.render()), // If not, render a placeholder. dom.maybe(not(boxModelAt), () => dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1))) ), ...args, ); function insertBox(childBox: FormLayoutNode) { // Make sure we have at least as many columns as the index we are inserting at. if (!box.parent) { throw new Error('No parent'); } return box.parent.replace(box, childBox); } async function removeColumn() { await box.deleteSelf(); } } } export function Placeholder(): FormLayoutNode { return {id: uuidv4(), type: 'Placeholder'}; } export function Columns(): FormLayoutNode { return {id: uuidv4(), type: 'Columns', children: [Placeholder(), Placeholder()]}; } const cssPlaceholder = styled('div', ` position: relative; & * { /* Otherwise it will emit drag events that we want to ignore to avoid flickering */ pointer-events: none; } `);