2024-02-21 19:22:01 +00:00
|
|
|
import {FormLayoutNode} from 'app/client/components/FormRenderer';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {buildEditor} from 'app/client/components/Forms/Editor';
|
2024-02-14 21:18:09 +00:00
|
|
|
import {FieldModel} from 'app/client/components/Forms/Field';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {buildMenu} from 'app/client/components/Forms/Menu';
|
2024-01-23 20:52:57 +00:00
|
|
|
import {BoxModel} from 'app/client/components/Forms/Model';
|
2023-12-12 09:58:20 +00:00
|
|
|
import * as style from 'app/client/components/Forms/styles';
|
|
|
|
import {makeTestId} from 'app/client/lib/domUtils';
|
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
|
|
import * as menus from 'app/client/ui2018/menus';
|
|
|
|
import {inlineStyle, not} from 'app/common/gutil';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
const testId = makeTestId('test-forms-');
|
|
|
|
|
|
|
|
export class ColumnsModel extends BoxModel {
|
|
|
|
private _columnCount = Computed.create(this, use => use(this.children).length);
|
|
|
|
|
|
|
|
public removeChild(box: BoxModel) {
|
2024-01-18 17:23:50 +00:00
|
|
|
if (box.type === 'Placeholder') {
|
2023-12-12 09:58:20 +00:00
|
|
|
// 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());
|
|
|
|
}
|
|
|
|
|
2024-01-24 16:14:34 +00:00
|
|
|
// Dropping a box on this component (Columns) directly will add it as a new column.
|
2024-02-21 19:22:01 +00:00
|
|
|
public accept(dropped: FormLayoutNode): BoxModel {
|
2023-12-12 09:58:20 +00:00
|
|
|
if (!this.parent) { throw new Error('No parent'); }
|
|
|
|
|
|
|
|
// We need to remove it from the parent, so find it first.
|
2024-01-24 16:14:34 +00:00
|
|
|
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;
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
droppedRef?.removeSelf();
|
|
|
|
|
2024-01-24 16:14:34 +00:00
|
|
|
return this.append(dropped);
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public render(...args: IDomArgs<HTMLElement>): HTMLElement {
|
2024-01-24 16:14:34 +00:00
|
|
|
const dragHover = Observable.create(null, false);
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
const content: HTMLElement = style.cssColumns(
|
2024-01-24 16:14:34 +00:00
|
|
|
dom.autoDispose(dragHover),
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
// 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) => {
|
2024-01-18 17:23:50 +00:00
|
|
|
const toRender = child ?? BoxModel.new(Placeholder(), this);
|
|
|
|
return toRender.render(testId('column'));
|
2023-12-12 09:58:20 +00:00
|
|
|
}),
|
|
|
|
|
|
|
|
// Append + button at the end.
|
2024-01-24 16:14:34 +00:00
|
|
|
cssPlaceholder(
|
2023-12-12 09:58:20 +00:00
|
|
|
testId('add'),
|
|
|
|
icon('Plus'),
|
|
|
|
dom.on('click', () => this.placeAfterListChild()(Placeholder())),
|
2024-01-24 16:14:34 +00:00
|
|
|
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);
|
|
|
|
}),
|
2023-12-12 09:58:20 +00:00
|
|
|
),
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
...args,
|
2023-12-12 09:58:20 +00:00
|
|
|
);
|
2024-01-18 17:23:50 +00:00
|
|
|
return buildEditor({ box: this, content });
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
2024-02-14 21:18:09 +00:00
|
|
|
|
|
|
|
public async deleteSelf(): Promise<void> {
|
|
|
|
// 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());
|
|
|
|
|
|
|
|
// Remove each child of this column from the layout.
|
|
|
|
this.children.get().forEach(child => { child.removeSelf(); });
|
|
|
|
|
|
|
|
// Remove this column from the layout.
|
|
|
|
this.removeSelf();
|
|
|
|
|
|
|
|
// Finally, remove the fields and save the changes to the layout.
|
|
|
|
await this.parent?.save(async () => {
|
|
|
|
if (fieldIdsToRemove.length > 0) {
|
|
|
|
await this.view.viewSection.removeField(fieldIdsToRemove);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export class PlaceholderModel extends BoxModel {
|
2024-01-18 17:23:50 +00:00
|
|
|
public render(...args: IDomArgs<HTMLElement>): HTMLElement {
|
|
|
|
const [box, view] = [this, this.view];
|
2023-12-12 09:58:20 +00:00
|
|
|
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(
|
2024-01-18 17:23:50 +00:00
|
|
|
style.cssDrop(),
|
|
|
|
testId('Placeholder'),
|
2024-01-24 16:14:34 +00:00
|
|
|
testId('element'),
|
|
|
|
dom.attr('data-box-model', String(box.type)),
|
2023-12-12 09:58:20 +00:00
|
|
|
dom.autoDispose(scope),
|
|
|
|
|
|
|
|
style.cssColumn.cls('-drag-over', dragHover),
|
|
|
|
style.cssColumn.cls('-empty', not(boxModelAt)),
|
|
|
|
style.cssColumn.cls('-selected', use => use(view.selectedBox) === box),
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
buildMenu({
|
|
|
|
box: this,
|
|
|
|
insertBox,
|
2023-12-12 09:58:20 +00:00
|
|
|
customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), '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;
|
2024-01-24 16:14:34 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
// Now we simply insert it after this box.
|
|
|
|
bundleChanges(() => {
|
2024-01-24 16:14:34 +00:00
|
|
|
droppedRef?.removeSelf();
|
2023-12-12 09:58:20 +00:00
|
|
|
const parent = box.parent!;
|
|
|
|
parent.replace(box, dropped);
|
|
|
|
parent.save().catch(reportError);
|
|
|
|
});
|
|
|
|
}),
|
2024-01-18 17:23:50 +00:00
|
|
|
// 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,
|
2023-12-12 09:58:20 +00:00
|
|
|
);
|
|
|
|
|
2024-02-21 19:22:01 +00:00
|
|
|
function insertBox(childBox: FormLayoutNode) {
|
2023-12-12 09:58:20 +00:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
|
|
|
|
function removeColumn() {
|
|
|
|
box.removeSelf();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-21 19:22:01 +00:00
|
|
|
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
|
2024-01-23 20:52:57 +00:00
|
|
|
return {type: 'Paragraph', text, alignment};
|
|
|
|
}
|
|
|
|
|
2024-02-21 19:22:01 +00:00
|
|
|
export function Placeholder(): FormLayoutNode {
|
2023-12-12 09:58:20 +00:00
|
|
|
return {type: 'Placeholder'};
|
|
|
|
}
|
|
|
|
|
2024-02-21 19:22:01 +00:00
|
|
|
export function Columns(): FormLayoutNode {
|
2023-12-12 09:58:20 +00:00
|
|
|
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
|
|
|
|
}
|
|
|
|
|
|
|
|
const cssPlaceholder = styled('div', `
|
|
|
|
position: relative;
|
2024-01-24 16:14:34 +00:00
|
|
|
& * {
|
|
|
|
/* Otherwise it will emit drag events that we want to ignore to avoid flickering */
|
|
|
|
pointer-events: none;
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
`);
|