(core) Forms feature

Summary:
A new widget type Forms. For now hidden behind GRIST_EXPERIMENTAL_PLUGINS().
This diff contains all the core moving parts as a serves as a base to extend this functionality
further.

Test Plan: New test added

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4130
This commit is contained in:
Jarosław Sadziński
2023-12-12 10:58:20 +01:00
parent 337757d0ba
commit a424450cbe
43 changed files with 4023 additions and 133 deletions

View File

@@ -0,0 +1,176 @@
import * as style from 'app/client/components/Forms/styles';
import {Box, BoxModel, RenderContext} from 'app/client/components/Forms/Model';
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';
import {bundleChanges, Computed, dom, MultiHolder, Observable, styled} from 'grainjs';
const testId = makeTestId('test-forms-');
export class ColumnsModel extends BoxModel {
private _columnCount = Computed.create(this, use => use(this.children).length);
public removeChild(box: BoxModel) {
if (box.type.get() === '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 a column will replace it.
public drop(dropped: Box): BoxModel {
if (!this.parent) { throw new Error('No parent'); }
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = this.root().find(droppedId);
// Now we simply insert it after this box.
droppedRef?.removeSelf();
return this.parent.replace(this, dropped);
}
public render(context: RenderContext): HTMLElement {
context.overlay.set(false);
// Now render the dom.
const renderedDom = style.cssColumns(
// 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) => {
return this.view.renderBox(
this.children,
child || BoxModel.new(Placeholder(), this),
testId('column')
);
}),
// Append + button at the end.
dom('div',
testId('add'),
icon('Plus'),
dom.on('click', () => this.placeAfterListChild()(Placeholder())),
style.cssColumn.cls('-add-button')
),
);
return renderedDom;
}
}
export class PlaceholderModel extends BoxModel {
public render(context: RenderContext): HTMLElement {
const [box, view, overlay] = [this, this.view, context.overlay];
const scope = new MultiHolder();
overlay.set(false);
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.cssDrag(),
testId('placeholder'),
dom.autoDispose(scope),
style.cssColumn.cls('-drag-over', dragHover),
style.cssColumn.cls('-empty', not(boxModelAt)),
style.cssColumn.cls('-selected', use => use(view.selectedBox) === box),
view.buildAddMenu(insertBox, {
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;
const droppedRef = box.root().find(droppedId);
if (!droppedRef) { return; }
// Now we simply insert it after this box.
bundleChanges(() => {
droppedRef.removeSelf();
const parent = box.parent!;
parent.replace(box, dropped);
parent.save().catch(reportError);
});
}),
dom.maybeOwned(boxModelAt, (mscope, child) => view.renderBox(mscope, child)),
dom.maybe(use => !use(boxModelAt) && use(view.isEdit), () => {
return dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1)));
}),
);
function insertBox(childBox: Box) {
// 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();
}
}
}
export function Placeholder(): Box {
return {type: 'Placeholder'};
}
export function Columns(): Box {
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
}
const cssPlaceholder = styled('div', `
position: relative;
`);

View File

@@ -0,0 +1,210 @@
import {FormView} from 'app/client/components/Forms/FormView';
import {Box, BoxModel, ignoreClick, RenderContext} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {Constructor} from 'app/common/gutil';
import {BindableValue, Computed, Disposable, dom, DomContents,
IDomComponent, makeTestId, Observable, toKo} from 'grainjs';
import * as ko from 'knockout';
const testId = makeTestId('test-forms-');
/**
* Base class for all field models.
*/
export class FieldModel extends BoxModel {
public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)()));
public field = this.view.gristDoc.docModel.viewFields.createFloatingRowModel(this.fieldRef);
public question = Computed.create(this, (use) => {
return use(this.field.question) || use(this.field.origLabel);
});
public description = Computed.create(this, (use) => {
return use(this.field.description);
});
public colType = Computed.create(this, (use) => {
return use(use(this.field.column).pureType);
});
public get leaf() {
return this.props['leaf'] as Observable<number>;
}
public renderer = Computed.create(this, (use) => {
const ctor = fieldConstructor(use(this.colType));
const instance = new ctor(this.field);
use.owner.autoDispose(instance);
return instance;
});
constructor(box: Box, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
}
public async onDrop() {
await super.onDrop();
if (typeof this.leaf.get() === 'string') {
this.leaf.set(await this.view.showColumn(this.leaf.get()));
}
}
public render(context: RenderContext) {
const model = this;
return dom('div',
testId('question'),
style.cssLabel(
testId('label'),
dom.text(model.question)
),
testType(this.colType),
dom.domComputed(this.renderer, (renderer) => renderer.buildDom()),
dom.maybe(model.description, (description) => [
style.cssDesc(description, testId('description')),
]),
);
}
public async deleteSelf() {
const rowId = this.field.getRowId();
const view = this.view;
this.removeSelf();
// The order here matters for undo.
await this.save();
// We are disposed at this point, be still can access the view.
if (rowId) {
await view.viewSection.removeField(rowId);
}
}
}
export abstract class Question extends Disposable implements IDomComponent {
constructor(public field: ViewFieldRec) {
super();
}
public abstract buildDom(): DomContents;
}
class TextModel extends Question {
public buildDom() {
return style.cssInput(
dom.prop('name', this.field.colId),
{type: 'text', tabIndex: "-1"},
ignoreClick
);
}
}
class ChoiceModel extends Question {
public buildDom() {
const field = this.field;
const choices: Computed<string[]> = Computed.create(this, use => {
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
});
return style.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.field.colId),
dom.forEach(choices, (choice) => dom('option', choice, {value: choice})),
);
}
}
class ChoiceListModel extends Question {
public buildDom() {
const field = this.field;
const choices: Computed<string[]> = Computed.create(this, use => {
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
});
return dom('div',
dom.prop('name', this.field.colId),
dom.forEach(choices, (choice) => style.cssLabel(
dom('input',
dom.prop('name', this.field.colId),
{type: 'checkbox', value: choice, style: 'margin-right: 5px;'}
),
choice
)),
dom.maybe(use => use(choices).length === 0, () => [
dom('div', 'No choices defined'),
]),
);
}
}
class BoolModel extends Question {
public buildDom() {
return dom('div',
style.cssLabel(
{style: 'display: flex; align-items: center; gap: 8px;'},
dom('input',
dom.prop('name', this.field.colId),
{type: 'checkbox', name: 'choice', style: 'margin: 0px; padding: 0px;'}
),
'Yes'
),
);
}
}
class DateModel extends Question {
public buildDom() {
return dom('div',
dom('input',
dom.prop('name', this.field.colId),
{type: 'date', style: 'margin-right: 5px; width: 100%;'
}),
);
}
}
class DateTimeModel extends Question {
public buildDom() {
return dom('div',
dom('input',
dom.prop('name', this.field.colId),
{type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'}
),
dom.style('width', '100%'),
);
}
}
// TODO: decide which one we need and implement rest.
const AnyModel = TextModel;
const NumericModel = TextModel;
const IntModel = TextModel;
const RefModel = TextModel;
const RefListModel = TextModel;
const AttachmentsModel = TextModel;
function fieldConstructor(type: string): Constructor<Question> {
switch (type) {
case 'Any': return AnyModel;
case 'Bool': return BoolModel;
case 'Choice': return ChoiceModel;
case 'ChoiceList': return ChoiceListModel;
case 'Date': return DateModel;
case 'DateTime': return DateTimeModel;
case 'Int': return IntModel;
case 'Numeric': return NumericModel;
case 'Ref': return RefModel;
case 'RefList': return RefListModel;
case 'Attachments': return AttachmentsModel;
default: return TextModel;
}
}
/**
* Creates a hidden input element with element type. Used in tests.
*/
function testType(value: BindableValue<string>) {
return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
}

View File

@@ -0,0 +1,679 @@
import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import {allCommands} from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import * as components from 'app/client/components/Forms/elements';
import {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {GristDoc} from 'app/client/components/GristDoc';
import {Disposable} from 'app/client/lib/dispose';
import {makeTestId} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset';
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {not} from 'app/common/gutil';
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs';
import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid';
const testId = makeTestId('test-forms-');
export class FormView extends Disposable {
public viewPane: HTMLElement;
public gristDoc: GristDoc;
public viewSection: ViewSectionRec;
public isEdit: Observable<boolean>;
public selectedBox: Observable<BoxModel | null>;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
protected cursor: Cursor;
protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _autoLayout: Computed<Box>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
private _url: Computed<string>;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
this.isEdit = Observable.create(this, true);
this.menuHolder = Holder.create(this);
this.bundle = (clb) => this.gristDoc.docData.bundleActions('Saving form layout', clb, {nestInActiveBundle: true});
this.selectedBox = Observable.create(this, null);
this.selectedBox.addListener((v) => {
if (!v) { return; }
const colRef = Number(v.prop('leaf').get());
if (!colRef || typeof colRef !== 'number') { return; }
const fieldIndex = this.viewSection.viewFields().all().findIndex(f => f.getRowId() === colRef);
if (fieldIndex === -1) { return; }
this.cursor.setCursorPos({fieldIndex});
});
this._autoLayout = Computed.create(this, use => {
// If the layout is already there, don't do anything.
const existing = use(this.viewSection.layoutSpecObj);
if (!existing || !existing.id) {
// Else create a temporary one.
const fields = use(use(this.viewSection.viewFields).getObservable());
const children: Box[] = fields.map(f => {
return {
type: 'Field',
leaf: use(f.id),
};
});
children.push({type: 'Submit'});
return {
type: 'Layout',
children,
};
}
return existing;
});
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async () => {
await this._saveNow();
}, this));
this._autoLayout.addListener((v) => {
if (this._saving) {
console.error('Layout changed while saving');
return;
}
// When the layout has changed, we will update the root, but only when it is not the same
// as the one we just saved.
if (isEqual(v, this._savedLayout)) { return; }
if (this._savedLayout) {
this._savedLayout = v;
}
this._root.update(v);
});
const keyboardActions = {
copy: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
// Add this box as a json to clipboard.
const json = selected.toJSON();
navigator.clipboard.writeText(JSON.stringify({
...json,
id: uuidv4(),
})).catch(reportError);
},
cut: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
selected.cutSelf().catch(reportError);
},
paste: () => {
const doPast = async () => {
const boxInClipboard = parseBox(await navigator.clipboard.readText());
if (!boxInClipboard) { return; }
if (!this.selectedBox.get()) {
this.selectedBox.set(this._root.insert(boxInClipboard, 0));
} else {
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
}
// Remove the orginal box from the clipboard.
const cutted = this._root.find(boxInClipboard.id);
cutted?.removeSelf();
await this._root.save();
await navigator.clipboard.writeText('');
};
doPast().catch(reportError);
},
nextField: () => {
const current = this.selectedBox.get();
const all = [...this._root.list()];
if (!all.length) { return; }
if (!current) {
this.selectedBox.set(all[0]);
} else {
const next = all[all.indexOf(current) + 1];
if (next) {
this.selectedBox.set(next);
} else {
this.selectedBox.set(all[0]);
}
}
},
prevField: () => {
const current = this.selectedBox.get();
const all = [...this._root.list()];
if (!all.length) { return; }
if (!current) {
this.selectedBox.set(all[all.length - 1]);
} else {
const next = all[all.indexOf(current) - 1];
if (next) {
this.selectedBox.set(next);
} else {
this.selectedBox.set(all[all.length - 1]);
}
}
},
lastField: () => {
const all = [...this._root.list()];
if (!all.length) { return; }
this.selectedBox.set(all[all.length - 1]);
},
firstField: () => {
const all = [...this._root.list()];
if (!all.length) { return; }
this.selectedBox.set(all[0]);
},
edit: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
(selected as any)?.edit?.set(true); // TODO: hacky way
},
clearValues: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
keyboardActions.nextField();
this.bundle(async () => {
await selected.deleteSelf();
}).catch(reportError);
},
insertFieldBefore: (type: {field: BoxType} | {structure: BoxType}) => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if ('field' in type) {
this.addNewQuestion(selected.placeBeforeMe(), type.field).catch(reportError);
} else {
selected.insertBefore(components.defaultElement(type.structure));
}
},
insertFieldAfter: (type: {field: BoxType} | {structure: BoxType}) => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if ('field' in type) {
this.addNewQuestion(selected.placeAfterMe(), type.field).catch(reportError);
} else {
selected.insertAfter(components.defaultElement(type.structure));
}
},
showColumns: (colIds: string[]) => {
this.bundle(async () => {
const boxes: Box[] = [];
for (const colId of colIds) {
const fieldRef = await this.viewSection.showColumn(colId);
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; }
const box = {
type: field.pureType.peek() as BoxType,
leaf: fieldRef,
};
boxes.push(box);
}
boxes.forEach(b => this._root.append(b));
await this._saveNow();
}).catch(reportError);
},
};
this.autoDispose(commands.createGroup({
...keyboardActions,
cursorDown: keyboardActions.nextField,
cursorUp: keyboardActions.prevField,
cursorLeft: keyboardActions.prevField,
cursorRight: keyboardActions.nextField,
shiftDown: keyboardActions.lastField,
shiftUp: keyboardActions.firstField,
editField: keyboardActions.edit,
deleteFields: keyboardActions.clearValues,
}, this, this.viewSection.hasFocus));
this._url = Computed.create(this, use => {
const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; }
const url = this.gristDoc.app.topAppModel.api.formUrl(doc.id, use(this.viewSection.id));
return url;
});
// Last line, build the dom.
this.viewPane = this.autoDispose(this.buildDom());
}
public insertColumn(colId?: string | null, options?: InsertColOptions) {
return this.viewSection.insertColumn(colId, {...options, nestInActiveBundle: true});
}
public showColumn(colRef: number|string, index?: number) {
return this.viewSection.showColumn(colRef, index);
}
public buildDom() {
return dom('div.flexauto.flexvbox',
this._buildSwitcher(),
style.cssFormEdit.cls('-preview', not(this.isEdit)),
style.cssFormEdit.cls('', this.isEdit),
testId('preview', not(this.isEdit)),
testId('editor', this.isEdit),
dom.maybe(this.isEdit, () => [
style.cssFormContainer(
dom.forEach(this._root.children, (child) => {
if (!child) {
// This shouldn't happen, and it is bad design, as columns allow nulls, where other container
// don't. But for now, just ignore it.
return dom('div', 'Empty node');
}
const element = this.renderBox(this._root.children, child);
if (Array.isArray(element)) {
throw new Error('Element is an array');
}
if (!(element instanceof HTMLElement)) {
throw new Error('Element is not an HTMLElement');
}
return element;
}),
this.buildDropzone(this, this._root.placeAfterListChild()),
),
]),
dom.maybe(not(this.isEdit), () => [
style.cssPreview(
dom.prop('src', this._url),
)
]),
dom.on('click', () => this.selectedBox.set(null))
);
}
public renderBox(owner: IDisposableOwner, box: BoxModel, ...args: IDomArgs<HTMLElement>): HTMLElement {
const overlay = Observable.create(owner, true);
return this.buildEditor(owner, {box, overlay},
dom.domComputedOwned(box.type, (scope, type) => {
const renderedElement = box.render({overlay});
const element = renderedElement;
return dom.update(
element,
testId('element'),
testId(box.type),
...args,
);
})
);
}
public buildDropzone(owner: IDisposableOwner, insert: Place, ...args: IDomArgs) {
const dragHover = Observable.create(owner, false);
const forceShow = Observable.create(owner, false);
return style.cssAddElement(
testId('dropzone'),
style.cssDrag(),
style.cssAddText(),
this.buildAddMenu(insert, {
onOpen: () => forceShow.set(true),
onClose: () => forceShow.set(false),
}),
style.cssAddElement.cls('-hover', use => use(dragHover)),
// And drop zone handlers
dom.on('drop', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
dragHover.set(false);
// Get the box that was dropped.
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = this._root.find(droppedId);
await this.bundle(async () => {
// Save the layout if it is not saved yet.
await this._saveNow();
// Remove the orginal box from the clipboard.
droppedRef?.removeSelf();
await insert(dropped).onDrop();
// Save the change.
await this._saveNow();
});
}),
dom.on('dragover', (ev) => {
ev.preventDefault();
ev.dataTransfer!.dropEffect = "move";
dragHover.set(true);
}),
dom.on('dragleave', (ev) => {
ev.preventDefault();
dragHover.set(false);
}),
style.cssAddElement.cls('-hover', dragHover),
...args,
);
}
public buildFieldPanel() {
return dom('div', 'Hello there');
}
public buildEditor(
owner: IDisposableOwner | null,
options: {
box: BoxModel,
overlay: Observable<boolean>
}
,
...args: IDomArgs
) {
const {box, overlay} = options;
const myOwner = new MultiHolder();
if (owner) {
owner.autoDispose(myOwner);
}
let element: HTMLElement;
const dragHover = Observable.create(myOwner, false);
myOwner.autoDispose(this.selectedBox.addListener(v => {
if (v !== box) { return; }
if (!element) { return; }
element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
}));
const isSelected = Computed.create(myOwner, use => {
if (!this.viewSection || this.viewSection.isDisposed()) { return false; }
if (use(this.selectedBox) === box) {
// We are only selected when the section is also selected.
return use(this.viewSection.hasFocus);
}
return false;
});
return style.cssFieldEditor(
testId('editor'),
style.cssDrag(),
dom.maybe(overlay, () => this.buildOverlay(myOwner, box)),
owner ? null : dom.autoDispose(myOwner),
(el) => { element = el; },
// Control panel
style.cssControls(
style.cssControlsLabel(dom.text(box.type)),
),
// Turn on active like state when we clicked here.
style.cssFieldEditor.cls('-selected', isSelected),
style.cssFieldEditor.cls('-cut', use => use(box.cut)),
testId('field-editor-selected', isSelected),
// Select on click.
(el) => {
dom.onElem(el, 'click', (ev) => {
// Only if the click was in this element.
const target = ev.target as HTMLElement;
if (!target.closest) { return; }
// Make sure that the closest editor is this one.
const closest = target.closest(`.${style.cssFieldEditor.className}`);
if (closest !== el) { return; }
// It looks like we clicked somewhere in this editor, and not inside any other inside.
this.selectedBox.set(box);
ev.stopPropagation();
ev.preventDefault();
ev.stopImmediatePropagation();
});
},
// Attach menu
menus.menu((ctl) => {
this.menuHolder.autoDispose(ctl);
this.selectedBox.set(box);
const field = (type: string) => ({field: type});
const struct = (structure: string) => ({structure});
const above = (el: {field: string} | {structure: string}) => () => allCommands.insertFieldBefore.run(el);
const below: typeof above = (el) => () => allCommands.insertFieldAfter.run(el);
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
const insertMenu = (where: typeof above) => () => {
return [
menus.menuSubHeader('New question'),
...commonTypes()
.filter(isQuick)
.map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName))
,
menus.menuItemSubmenu(
() => commonTypes()
.filter(notQuick)
.map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)),
{},
menus.menuIcon('Dots'),
dom('span', "More", dom.style('margin-right', '8px'))
),
menus.menuDivider(),
menus.menuSubHeader('Static element'),
menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
// menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ),
];
};
return [
menus.menuItemSubmenu(insertMenu(above), {action: above(field('Text'))}, "Insert question above"),
menus.menuItemSubmenu(insertMenu(below), {action: below(field('Text'))}, "Insert question below"),
menus.menuDivider(),
menus.menuItemCmd(allCommands.contextMenuCopy, "Copy"),
menus.menuItemCmd(allCommands.contextMenuCut, "Cut"),
menus.menuItemCmd(allCommands.contextMenuPaste, "Paste"),
menus.menuDivider(),
menus.menuItemCmd(allCommands.deleteFields, "Hide"),
];
}, {trigger: ['contextmenu']}),
dom.on('contextmenu', (ev) => {
ev.stopPropagation();
ev.preventDefault();
}),
// And now drag and drop support.
{draggable: "true"},
// When started, we just put the box into the dataTransfer as a plain text.
// TODO: this might be very sofisticated in the future.
dom.on('dragstart', (ev) => {
// Prevent propagation, as we might be in a nested editor.
ev.stopPropagation();
ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON()));
ev.dataTransfer!.dropEffect = "move";
}),
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('dragleave', (ev) => {
ev.stopPropagation();
ev.preventDefault();
// Just remove the style and stop propagation.
dragHover.set(false);
}),
dom.on('drop', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
dragHover.set(false);
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
if (droppedId === box.id) { return; }
const droppedRef = this._root.find(droppedId);
await this.bundle(async () => {
await this._root.save();
droppedRef?.removeSelf();
await box.drop(dropped)?.onDrop();
await this._saveNow();
});
}),
style.cssFieldEditor.cls('-drag-hover', dragHover),
...args,
);
}
public buildOverlay(owner: IDisposableOwner, box: BoxModel) {
return style.cssSelectedOverlay(
);
}
public async addNewQuestion(insert: Place, type: string) {
await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => {
// First save the layout, so that
await this._saveNow();
// Now that the layout is saved, we won't be bottered with autogenerated layout,
// and we can safely insert to column.
const {fieldRef} = await this.insertColumn(null, {
colInfo: {
type,
}
});
// And add it into the layout.
this.selectedBox.set(insert({
leaf: fieldRef,
type: 'Field'
}));
await this._root.save();
}, {nestInActiveBundle: true});
}
public buildAddMenu(insert: Place, {
onClose: onClose = () => {},
onOpen: onOpen = () => {},
customItems = [] as Element[],
} = {}) {
return menus.menu(
(ctl) => {
onOpen();
ctl.onDispose(onClose);
const field = (colType: BoxType) => ({field: colType});
const struct = (structure: BoxType) => ({structure});
const where = (el: {field: string} | {structure: BoxType}) => () => {
if ('field' in el) {
return this.addNewQuestion(insert, el.field);
} else {
insert(components.defaultElement(el.structure));
return this._root.save();
}
};
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
return [
menus.menuSubHeader('New question'),
...commonTypes()
.filter(isQuick)
.map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName))
,
menus.menuItemSubmenu(
() => commonTypes()
.filter(notQuick)
.map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)),
{},
menus.menuIcon('Dots'),
dom('span', "More", dom.style('margin-right', '8px'))
),
menus.menuDivider(),
menus.menuSubHeader('Static element'),
menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
// menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ),
elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}),
customItems.length ? menus.menuDivider(dom.style('min-width', '200px')) : null,
...customItems,
];
},
{
selectOnOpen: true,
trigger: [
'click',
],
}
);
}
private async _saveNow() {
try {
this._saving = true;
const newVersion = {...this._root.toJSON()};
// If nothing has changed, don't bother.
if (isEqual(newVersion, this._savedLayout)) { return; }
this._savedLayout = newVersion;
await this.viewSection.layoutSpecObj.setAndSave(newVersion);
} finally {
this._saving = false;
}
}
private _buildSwitcher() {
const toggle = (val: boolean) => () => {
this.isEdit.set(val);
this._saveNow().catch(reportError);
};
return style.cssButtonGroup(
style.cssIconButton(
icon('Pencil'),
testId('edit'),
dom('div', 'Editor'),
cssButton.cls('-primary', this.isEdit),
style.cssIconButton.cls('-standard', not(this.isEdit)),
dom.on('click', toggle(true))
),
style.cssIconButton(
icon('EyeShow'),
dom('div', 'Preview'),
testId('preview'),
cssButton.cls('-primary', not(this.isEdit)),
style.cssIconButton.cls('-standard', (this.isEdit)),
dom.on('click', toggle(false))
),
style.cssIconLink(
icon('FieldAttachment'),
testId('link'),
dom('div', 'Link'),
dom.prop('href', this._url),
{target: '_blank'}
),
);
}
}
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
defaults(FormView.prototype, BaseView.prototype);
Object.assign(FormView.prototype, BackboneEvents);

View File

@@ -0,0 +1,140 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {cssButton} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, fromKo, makeTestId, styled} from 'grainjs';
import * as ko from 'knockout';
const testId = makeTestId('test-vfc-');
const t = makeT('VisibleFieldsConfig');
/**
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
* the ability to drag and drop fields onto the form.
*/
export class HiddenQuestionConfig extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
}
public buildDom() {
const hiddenColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
return this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
})));
return [
cssHeader(
cssFieldListHeader(dom.text(t("Hidden fields"))),
),
dom('div',
testId('hidden-fields'),
dom.forEach(hiddenColumns, (field) => {
return this._buildHiddenFieldItem(field);
})
)
];
}
private _buildHiddenFieldItem(column: ColumnRec) {
return cssDragRow(
testId('hidden-field'),
{draggable: "true"},
dom.on('dragstart', (ev) => {
// Prevent propagation, as we might be in a nested editor.
ev.stopPropagation();
ev.dataTransfer?.setData('text/plain', JSON.stringify({
type: 'Field',
leaf: column.colId.peek(), // TODO: convert to Field
}));
ev.dataTransfer!.dropEffect = "move";
}),
cssSimpleDragger(),
cssFieldEntry(
cssFieldLabel(dom.text(column.label)),
cssHideIcon('EyeShow',
testId('hide'),
dom.on('click', () => {
allCommands.showColumns.run([column.colId.peek()]);
}),
),
),
);
}
}
// TODO: reuse them
const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;
margin-bottom: 2px;
cursor: grab;
`);
const cssFieldEntry = styled('div', `
display: flex;
background-color: ${theme.hover};
border-radius: 2px;
margin: 0 8px 0 0;
padding: 4px 8px;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
--icon-color: ${theme.lightText};
`);
const cssSimpleDragger = styled(cssDragger, `
cursor: grab;
.${cssDragRow.className}:hover & {
visibility: visible;
}
`);
const cssHideIcon = styled(icon, `
--icon-color: ${theme.lightText};
display: none;
cursor: pointer;
flex: none;
margin-right: 8px;
.${cssFieldEntry.className}:hover & {
display: block;
}
`);
const cssFieldLabel = styled('span', `
color: ${theme.text};
flex: 1 1 auto;
text-overflow: ellipsis;
overflow: hidden;
`);
const cssFieldListHeader = styled('span', `
color: ${theme.text};
flex: 1 1 0px;
font-size: ${vars.xsmallFontSize};
text-transform: uppercase;
`);
const cssRow = styled('div', `
display: flex;
margin: 16px;
overflow: hidden;
--icon-color: ${theme.lightText};
& > .${cssButton.className} {
margin-right: 8px;
}
`);
const cssHeader = styled(cssRow, `
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
`);

View File

@@ -0,0 +1,378 @@
import {FormView} from 'app/client/components/Forms/FormView';
import * as elements from 'app/client/components/Forms/elements';
import {
bundleChanges, Computed, Disposable, dom, DomContents,
MultiHolder, MutableObsArray, obsArray, Observable
} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field';
/**
* Box model is a JSON that represents a form element. Every element can be converted to this element and every
* ViewModel should be able to read it and built itself from it.
*/
export interface Box extends Record<string, any> {
type: BoxType,
children?: Array<Box>,
}
/**
* A place where to insert a box.
*/
export type Place = (box: Box) => BoxModel;
/**
* View model constructed from a box JSON structure.
*/
export abstract class BoxModel extends Disposable {
/**
* A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type.
*/
public static new(box: Box, parent: BoxModel | null, view: FormView | null = null): BoxModel {
const subClassName = `${box.type.split(':')[0]}Model`;
const factories = elements as any;
const factory = factories[subClassName];
// If we have a factory, use it.
if (factory) {
return new factory(box, parent, view || parent!.view);
}
// Otherwise, use the default.
return new DefaultBoxModel(box, parent, view || parent!.view);
}
/**
* The id of the created box. The value here is not important. It is only used as a plain old pointer to this
* element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need
* it as box are serialized to JSON and put into clipboard, and we need to be able to find them back.
*/
public id: string;
/**
* Type of the box. As the type is bounded to the class that is used to render the box, it is possible
* to change the type of the box just by changing this value. The box is then replaced in the parent.
*/
public type: Observable<string>;
/**
* List of children boxes.
*/
public children: MutableObsArray<BoxModel>;
/**
* Any other dynamically added properties (that are not concrete fields in the derived classes)
*/
public props: Record<string, Observable<any>> = {};
/**
* Publicly exposed state if the element was just cut.
* TODO: this should be moved to FormView, as this model doesn't care about that.
*/
public cut = Observable.create(this, false);
/**
* Don't use it directly, use the BoxModel.new factory method instead.
*/
constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
super();
// Store "pointer" to this element.
this.id = uuidv4();
// Create observables for all properties.
this.type = Observable.create(this, box.type);
this.children = this.autoDispose(obsArray([]));
// And now update this and all children based on the box JSON.
bundleChanges(() => {
this.update(box);
});
// Some boxes need to do some work after initialization, so we call this method.
// Of course, they also can override the constructor, but this is a bit easier.
this.onCreate();
}
/**
* Public method that should be called when this box is dropped somewhere. In derived classes
* this method can send some actions to the server, or do some other work. In particular Field
* will insert or reveal a column.
*/
public async onDrop() {
}
/**
* The only method that derived classes need to implement. It should return a DOM element that
* represents this box.
*/
public abstract render(context: RenderContext): HTMLElement;
public removeChild(box: BoxModel) {
const myIndex = this.children.get().indexOf(box);
if (myIndex < 0) { throw new Error('Cannot remove box that is not in parent'); }
this.children.splice(myIndex, 1);
}
/**
* Remove self from the parent without saving.
*/
public removeSelf() {
this.parent?.removeChild(this);
}
/**
* Remove self from the parent and save. Use to bundle layout save with any other changes.
* See Fields for the implementation.
* TODO: this is needed as action bundling is very limited.
*/
public async deleteSelf() {
const parent = this.parent;
this.removeSelf();
await parent!.save();
}
/**
* Cuts self and puts it into clipboard.
*/
public async cutSelf() {
[...this.root().list()].forEach(box => box?.cut.set(false));
// Add this box as a json to clipboard.
await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
this.cut.set(true);
}
/**
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
* as a first child. Default implementation is to insert before self.
*/
public drop(dropped: Box) {
// Get the box that was dropped.
if (!dropped) { return null; }
if (dropped.id === this.id) {
return null;
}
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = this.root().find(droppedId);
if (droppedRef) {
droppedRef.removeSelf();
}
return this.placeBeforeMe()(dropped);
}
public prop(name: string, defaultValue?: any) {
if (!this.props[name]) {
this.props[name] = Observable.create(this, defaultValue ?? null);
}
return this.props[name];
}
public async save(): Promise<void> {
if (!this.parent) { throw new Error('Cannot save detached box'); }
return this.parent.save();
}
/**
* Replaces children at index.
*/
public replaceAtIndex(box: Box, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 1, newOne);
return newOne;
}
public append(box: Box) {
const newOne = BoxModel.new(box, this);
this.children.push(newOne);
return newOne;
}
public insert(box: Box, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 0, newOne);
return newOne;
}
/**
* Replaces existing box with a new one, whenever it is found.
*/
public replace(existing: BoxModel, newOne: Box|BoxModel) {
const index = this.children.get().indexOf(existing);
if (index < 0) { throw new Error('Cannot replace box that is not in parent'); }
const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this);
model.parent = this;
model.view = this.view;
this.children.splice(index, 1, model);
return model;
}
/**
* Creates a place to insert a box before this box.
*/
public placeBeforeFirstChild() {
return (box: Box) => this.insert(box, 0);
}
// Some other places.
public placeAfterListChild() {
return (box: Box) => this.insert(box, this.children.get().length);
}
public placeAt(index: number) {
return (box: Box) => this.insert(box, index);
}
public placeAfterChild(child: BoxModel) {
return (box: Box) => this.insert(box, this.children.get().indexOf(child) + 1);
}
public placeAfterMe() {
return this.parent!.placeAfterChild(this);
}
public placeBeforeMe() {
return this.parent!.placeAt(this.parent!.children.get().indexOf(this));
}
public insertAfter(json: any) {
return this.parent!.insert(json, this.parent!.children.get().indexOf(this) + 1);
}
public insertBefore(json: any) {
return this.parent!.insert(json, this.parent!.children.get().indexOf(this));
}
public root() {
let root: BoxModel = this;
while (root.parent) { root = root.parent; }
return root;
}
/**
* Finds a box with a given id in the tree.
*/
public find(droppedId: string): BoxModel | null {
for (const child of this.kids()) {
if (child.id === droppedId) { return child; }
const found = child.find(droppedId);
if (found) { return found; }
}
return null;
}
public kids() {
return this.children.get().filter(Boolean);
}
/**
* The core responsibility of this method is to update this box and all children based on the box JSON.
* This is counterpart of the FloatingRowModel, that enables this instance to point to a different box.
*/
public update(boxDef: Box) {
// If we have a type and the type is changed, then we need to replace the box.
if (this.type.get() && boxDef.type !== this.type.get()) {
this.parent!.replace(this, BoxModel.new(boxDef, this.parent));
return;
}
// Update all properties of self.
for (const key in boxDef) {
if (key === 'id' || key === 'type' || key === 'children') { continue; }
if (!boxDef.hasOwnProperty(key)) { continue; }
if (this.prop(key).get() === boxDef[key]) { continue; }
this.prop(key).set(boxDef[key]);
}
// Add or delete any children that were removed or added.
const myLength = this.children.get().length;
const newLength = boxDef.children ? boxDef.children.length : 0;
if (myLength > newLength) {
this.children.splice(newLength, myLength - newLength);
} else if (myLength < newLength) {
for (let i = myLength; i < newLength; i++) {
const toPush = boxDef.children![i];
this.children.push(toPush && BoxModel.new(toPush, this));
}
}
// Update those that indices are the same.
const min = Math.min(myLength, newLength);
for (let i = 0; i < min; i++) {
const atIndex = this.children.get()[i];
const atIndexDef = boxDef.children![i];
atIndex.update(atIndexDef);
}
}
/**
* Serialize this box to JSON.
*/
public toJSON(): Box {
return {
id: this.id,
type: this.type.get() as BoxType,
children: this.children.get().map(child => child?.toJSON() || null),
...(Object.fromEntries(Object.entries(this.props).map(([key, val]) => [key, val.get()]))),
};
}
public * list(): IterableIterator<BoxModel> {
for (const child of this.kids()) {
yield child;
yield* child.list();
}
}
protected onCreate() {
}
}
export class LayoutModel extends BoxModel {
constructor(box: Box, public parent: BoxModel | null, public _save: () => Promise<void>, public view: FormView) {
super(box, parent, view);
}
public async save() {
return await this._save();
}
public render(): HTMLElement {
throw new Error('Method not implemented.');
}
}
class DefaultBoxModel extends BoxModel {
public render(): HTMLElement {
return dom('div', `Unknown box type ${this.type.get()}`);
}
}
export interface RenderContext {
overlay: Observable<boolean>,
}
export type Builder = (owner: MultiHolder, options: {
box: BoxModel,
view: FormView,
overlay: Observable<boolean>,
}) => DomContents;
export const ignoreClick = dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
});
export function unwrap<T>(val: T | Computed<T>): T {
return val instanceof Computed ? val.get() : val;
}
export function parseBox(text: string) {
try {
const json = JSON.parse(text);
return json && typeof json === 'object' && json.type ? json : null;
} catch (e) {
return null;
}
}

View File

@@ -0,0 +1,94 @@
import * as css from './styles';
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {textarea} from 'app/client/ui/inputs';
import {theme} from 'app/client/ui2018/cssVars';
import {Computed, dom, Observable, styled} from 'grainjs';
export class ParagraphModel extends BoxModel {
public edit = Observable.create(this, false);
public render(context: RenderContext) {
const box = this;
context.overlay.set(false);
const editMode = box.edit;
let element: HTMLElement;
const text = this.prop('text', '**Lorem** _ipsum_ dolor') as Observable<string|undefined>;
const properText = Computed.create(this, (use) => {
const savedText = use(text);
if (!savedText) { return ''; }
if (typeof savedText !== 'string') { return ''; }
return savedText;
});
properText.onWrite((val) => {
if (typeof val !== 'string') { return; }
text.set(val);
this.parent?.save().catch(reportError);
});
box.edit.addListener((val) => {
if (!val) { return; }
setTimeout(() => element.focus(), 0);
});
return css.cssStaticText(
css.markdown(use => use(properText) || '', dom.cls('_preview'), dom.hide(editMode)),
dom.maybe(use => !use(properText) && !use(editMode), () => cssEmpty('(empty)')),
dom.on('dblclick', () => {
editMode.set(true);
}),
css.cssStaticText.cls('-edit', editMode),
dom.maybe(editMode, () => [
cssTextArea(properText, {},
(el) => {
element = el;
},
dom.onKeyDown({
Enter$: (ev) => {
// if shift ignore
if (ev.shiftKey) {
return;
}
ev.stopPropagation();
ev.preventDefault();
editMode.set(false);
},
Escape$: (ev) => {
ev.stopPropagation();
ev.preventDefault();
editMode.set(false);
}
}),
dom.on('blur', () => {
editMode.set(false);
}),
),
])
);
}
}
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};
border: 0px;
width: 100%;
padding: 3px 6px;
outline: none;
max-height: 300px;
min-height: calc(3em * 1.5);
resize: none;
border-radius: 3px;
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
&[readonly] {
background-color: ${theme.inputDisabledBg};
color: ${theme.inputDisabledFg};
}
`);
const cssEmpty = styled('div', `
color: ${theme.inputPlaceholderFg};
font-style: italic;
`);

View File

@@ -0,0 +1,25 @@
import * as style from './styles';
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {dom} from 'grainjs';
/**
* Component that renders a section of the form.
*/
export class SectionModel extends BoxModel {
public render(context: RenderContext) {
const children = this.children;
context.overlay.set(false);
const view = this.view;
const box = this;
const element = style.cssSection(
style.cssDrag(),
dom.forEach(children, (child) =>
child ? view.renderBox(children, child) : dom('div', 'Empty')
),
view.buildDropzone(children, box.placeAfterListChild()),
);
return element;
}
}

View File

@@ -0,0 +1,10 @@
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {makeTestId} from 'app/client/lib/domUtils';
import {primaryButton} from 'app/client/ui2018/buttons';
const testId = makeTestId('test-forms-');
export class SubmitModel extends BoxModel {
public render(context: RenderContext) {
return primaryButton('Submit', testId('submit'));
}
}

View File

@@ -0,0 +1,26 @@
import * as style from './styles';
import {Builder, ignoreClick} from 'app/client/components/Forms/Model';
import {Computed, dom, IDisposableOwner, makeTestId} from 'grainjs';
const testId = makeTestId('test-forms-');
export const buildTextField: Builder = (owner: IDisposableOwner, {box, view}) => {
const field = Computed.create(owner, use => {
return view.gristDoc.docModel.viewFields.getRowModel(use(box.prop('leaf')));
});
return dom('div',
testId('question'),
testId('question-Text'),
style.cssLabel(
testId('label'),
dom.text(use => use(use(field).question) || use(use(field).origLabel))
),
style.cssInput(
testId('input'),
{type: 'text', tabIndex: "-1"},
ignoreClick),
dom.maybe(use => use(use(field).description), (description) => [
style.cssDesc(description, testId('description')),
]),
);
};

View File

@@ -0,0 +1,20 @@
import {Columns, Placeholder} from 'app/client/components/Forms/Columns';
import {Box, BoxType} from 'app/client/components/Forms/Model';
/**
* Add any other element you whish to use in the form here.
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
* to render and manage the element.
*/
export * from "./Paragraph";
export * from "./Section";
export * from './Field';
export * from './Columns';
export * from './Submit';
export function defaultElement(type: BoxType): Box {
switch(type) {
case 'Columns': return Columns();
case 'Placeholder': return Placeholder();
default: return {type};
}
}

View File

@@ -0,0 +1,350 @@
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
import {BindableValue, dom, IDomArgs, styled, subscribeBindable} from 'grainjs';
import {marked} from 'marked';
export {
cssLabel,
cssDesc,
cssInput,
cssFieldEditor,
cssSelectedOverlay,
cssControls,
cssControlsLabel,
cssAddElement,
cssAddText,
cssFormContainer,
cssFormEdit,
cssSection,
cssStaticText,
};
const cssFormEdit = styled('div', `
color: ${theme.text};
background-color: ${theme.leftPanelBg};
display: flex;
flex-direction: column;
flex-basis: 0px;
align-items: center;
padding-top: 52px;
position: relative;
padding-bottom: 32px;
--section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */
&, &-preview {
overflow: auto;
min-height: 100%;
width: 100%;
position: relative;
flex-basis: 0px;
}
`);
const cssLabel = styled('label', `
font-size: 15px;
font-weight: normal;
margin-bottom: 8px;
user-select: none;
display: block;
`);
const cssDesc = styled('div', `
font-size: 10px;
font-weight: 400;
margin-top: 4px;
color: ${colors.slate};
white-space: pre-wrap;
`);
const cssInput = styled('input', `
flex: auto;
width: 100%;
font-size: inherit;
padding: 4px 8px;
border: 1px solid #D9D9D9;
border-radius: 3px;
outline: none;
cursor-events: none;
&-invalid {
color: red;
}
`);
export const cssSelect = styled('select', `
flex: auto;
width: 100%;
font-size: inherit;
padding: 4px 8px;
border: 1px solid #D9D9D9;
border-radius: 3px;
outline: none;
cursor-events: none;
&-invalid {
color: red;
}
`);
const cssFieldEditor = styled('div._cssFieldEditor', `
position: relative;
cursor: pointer;
user-select: none;
outline: none;
&:hover:not(:has(&:hover)), &-selected {
outline: 1px solid ${colors.lightGreen};
}
&:active:not(:has(&:active)) {
outline: 1px solid ${colors.darkGreen};
}
&-drag-hover {
outline: 2px dashed ${colors.lightGreen};
outline-offset: 2px;
}
&-cut {
outline: 2px dashed ${colors.orange};
outline-offset: 2px;
}
.${cssFormEdit.className}-preview & {
outline: 0px !import;
}
`);
const cssSelectedOverlay = styled('div', `
background: ${colors.selection};
inset: 0;
position: absolute;
opacity: 0;
outline: none;
.${cssFieldEditor.className}-selected > & {
opacity: 1;
}
.${cssFormEdit.className}-preview & {
display: none;
}
`);
const cssControls = styled('div', `
display: none;
position: absolute;
margin-top: -18px;
margin-left: -1px;
.${cssFieldEditor.className}:hover:not(:has(.${cssFieldEditor.className}:hover)) > &,
.${cssFieldEditor.className}:active:not(:has(.${cssFieldEditor.className}:active)) > &,
.${cssFieldEditor.className}-selected > & {
display: flex;
}
.${cssFormEdit.className}-preview & {
display: none !important;
}
`);
const cssControlsLabel = styled('div', `
background: ${colors.lightGreen};
color: ${colors.light};
padding: 1px 2px;
min-width: 24px;
`);
const cssAddElement = styled('div', `
position: relative;
min-height: 32px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding-right: 8px;
--icon-color: ${colors.lightGreen};
align-self: stretch;
border: 2px dashed ${colors.darkGrey};
background: ${colors.lightGrey};
opacity: 0.7;
&:hover {
border: 2px dashed ${colors.darkGrey};
background: ${colors.lightGrey};
opacity: 1;
}
&-hover {
outline: 2px dashed ${colors.lightGreen};
outline-offset: 2px;
}
`);
const cssAddText = styled('div', `
color: ${colors.slate};
border-radius: 4px;
padding: 2px 4px;
font-size: 12px;
z-index: 1;
&:before {
content: "Add a field";
}
.${cssAddElement.className}-hover &:before {
content: "Drop here";
}
`);
const cssSection = styled('div', `
position: relative;
background-color: var(--section-background);
color: ${theme.text};
align-self: center;
margin: 0px auto;
border-radius: 8px;
display: flex;
flex-direction: column;
min-height: 50px;
padding: 10px;
.${cssFormEdit.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
min-height: auto;
}
`);
export const cssColumns = styled('div', `
--css-columns-count: 2;
background-color: var(--section-background);
display: grid;
grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px;
gap: 8px;
padding: 12px 4px;
.${cssFormEdit.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
grid-template-columns: repeat(var(--css-columns-count), 1fr);
min-height: auto;
}
`);
export const cssColumn = styled('div', `
position: relative;
&-empty, &-add-button {
position: relative;
min-height: 32px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding-right: 8px;
--icon-color: ${colors.slate};
align-self: stretch;
transition: height 0.2s ease-in-out;
border: 2px dashed ${colors.darkGrey};
background: ${colors.lightGrey};
color: ${colors.slate};
border-radius: 4px;
padding: 2px 4px;
font-size: 12px;
}
&-selected {
border: 2px dashed ${colors.slate};
}
&-empty:hover, &-add-button:hover {
border: 2px dashed ${colors.slate};
}
&-drag-over {
outline: 2px dashed ${colors.lightGreen};
}
&-add-button {
align-self: flex-end;
}
.${cssFormEdit.className}-preview &-add-button {
display: none;
}
.${cssFormEdit.className}-preview &-empty {
background: transparent;
border-radius: unset;
padding: 0px;
min-height: auto;
border: 0px;
}
`);
const cssFormContainer = styled('div', `
padding: 32px;
background-color: ${theme.mainPanelBg};
border: 1px solid ${theme.menuBorder};
color: ${theme.text};
width: 640px;
align-self: center;
margin: 0px auto;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: calc(100% - 32px);
`);
export const cssButtonGroup = styled('div', `
position: absolute;
top: 18px;
left: 24px;
right: 24px;
display: flex;
justify-content: center;
gap: 8px;
`);
export const cssIconButton = styled(basicButton, `
padding: 3px 8px;
font-size: ${vars.smallFontSize};
display: flex;
align-items: center;
gap: 4px;
&-standard {
background-color: ${theme.leftPanelBg};
}
`);
export const cssIconLink = styled(basicButtonLink, `
padding: 3px 8px;
font-size: ${vars.smallFontSize};
display: flex;
align-items: center;
gap: 4px;
background-color: ${theme.leftPanelBg};
`);
const cssStaticText = styled('div', `
min-height: 1.5rem;
`);
export function markdown(obs: BindableValue<string>, ...args: IDomArgs<HTMLDivElement>) {
return dom('div', el => {
dom.autoDisposeElem(el, subscribeBindable(obs, val => {
el.innerHTML = sanitizeHTML(marked(val));
}));
}, ...args);
}
export const cssDrag = styled('div.test-forms-drag', `
position: absolute;
pointer-events: none;
top: 2px;
left: 2px;
width: 1px;
height: 1px;
`);
export const cssPreview = styled('iframe', `
height: 100%;
border: 0px;
`);