mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
337757d0ba
commit
a424450cbe
176
app/client/components/Forms/Columns.ts
Normal file
176
app/client/components/Forms/Columns.ts
Normal 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;
|
||||||
|
`);
|
210
app/client/components/Forms/Field.ts
Normal file
210
app/client/components/Forms/Field.ts
Normal 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'));
|
||||||
|
}
|
679
app/client/components/Forms/FormView.ts
Normal file
679
app/client/components/Forms/FormView.ts
Normal 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);
|
140
app/client/components/Forms/HiddenQuestionConfig.ts
Normal file
140
app/client/components/Forms/HiddenQuestionConfig.ts
Normal 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;
|
||||||
|
`);
|
378
app/client/components/Forms/Model.ts
Normal file
378
app/client/components/Forms/Model.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
94
app/client/components/Forms/Paragraph.ts
Normal file
94
app/client/components/Forms/Paragraph.ts
Normal 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;
|
||||||
|
`);
|
25
app/client/components/Forms/Section.ts
Normal file
25
app/client/components/Forms/Section.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
10
app/client/components/Forms/Submit.ts
Normal file
10
app/client/components/Forms/Submit.ts
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
26
app/client/components/Forms/Text.ts
Normal file
26
app/client/components/Forms/Text.ts
Normal 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')),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
};
|
20
app/client/components/Forms/elements.ts
Normal file
20
app/client/components/Forms/elements.ts
Normal 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};
|
||||||
|
}
|
||||||
|
}
|
350
app/client/components/Forms/styles.ts
Normal file
350
app/client/components/Forms/styles.ts
Normal 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;
|
||||||
|
`);
|
@ -913,8 +913,18 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
if (name === undefined) {
|
if (name === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let newViewId: IDocPage;
|
||||||
|
if (val.type === 'record') {
|
||||||
const result = await this.docData.sendAction(['AddEmptyTable', name]);
|
const result = await this.docData.sendAction(['AddEmptyTable', name]);
|
||||||
await this.openDocPage(result.views[0].id);
|
newViewId = result.views[0].id;
|
||||||
|
} else {
|
||||||
|
// This will create a new table and page.
|
||||||
|
const result = await this.docData.sendAction(
|
||||||
|
['CreateViewSection', /* new table */0, 0, val.type, null, name]
|
||||||
|
);
|
||||||
|
newViewId = result.viewRef;
|
||||||
|
}
|
||||||
|
await this.openDocPage(newViewId);
|
||||||
} else {
|
} else {
|
||||||
let result: any;
|
let result: any;
|
||||||
await this.docData.bundleActions(`Add new page`, async () => {
|
await this.docData.bundleActions(`Add new page`, async () => {
|
||||||
|
@ -5,6 +5,7 @@ import * as commands from 'app/client/components/commands';
|
|||||||
import {CustomCalendarView} from "app/client/components/CustomCalendarView";
|
import {CustomCalendarView} from "app/client/components/CustomCalendarView";
|
||||||
import {CustomView} from 'app/client/components/CustomView';
|
import {CustomView} from 'app/client/components/CustomView';
|
||||||
import * as DetailView from 'app/client/components/DetailView';
|
import * as DetailView from 'app/client/components/DetailView';
|
||||||
|
import {FormView} from 'app/client/components/Forms/FormView';
|
||||||
import * as GridView from 'app/client/components/GridView';
|
import * as GridView from 'app/client/components/GridView';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {BoxSpec, Layout} from 'app/client/components/Layout';
|
import {BoxSpec, Layout} from 'app/client/components/Layout';
|
||||||
@ -44,6 +45,7 @@ const viewSectionTypes: {[key: string]: any} = {
|
|||||||
chart: ChartView,
|
chart: ChartView,
|
||||||
single: DetailView,
|
single: DetailView,
|
||||||
custom: CustomView,
|
custom: CustomView,
|
||||||
|
form: FormView,
|
||||||
'custom.calendar': CustomCalendarView,
|
'custom.calendar': CustomCalendarView,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -114,6 +114,7 @@ export type CommandName =
|
|||||||
| 'detachEditor'
|
| 'detachEditor'
|
||||||
| 'activateAssistant'
|
| 'activateAssistant'
|
||||||
| 'viewAsCard'
|
| 'viewAsCard'
|
||||||
|
| 'showColumns'
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|
||||||
@ -125,6 +126,11 @@ export interface CommandDef {
|
|||||||
deprecated?: boolean;
|
deprecated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MenuCommand {
|
||||||
|
humanKeys: string[];
|
||||||
|
run: (...args: any[]) => any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommendGroupDef {
|
export interface CommendGroupDef {
|
||||||
group: string;
|
group: string;
|
||||||
commands: CommandDef[];
|
commands: CommandDef[];
|
||||||
@ -595,7 +601,11 @@ export const groups: CommendGroupDef[] = [{
|
|||||||
name: 'duplicateRows',
|
name: 'duplicateRows',
|
||||||
keys: ['Mod+Shift+d'],
|
keys: ['Mod+Shift+d'],
|
||||||
desc: 'Duplicate selected rows'
|
desc: 'Duplicate selected rows'
|
||||||
},
|
}, {
|
||||||
|
name: 'showColumns',
|
||||||
|
keys: [],
|
||||||
|
desc: 'Show hidden columns'
|
||||||
|
}
|
||||||
],
|
],
|
||||||
}, {
|
}, {
|
||||||
group: 'Sorting',
|
group: 'Sorting',
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
|
import {useBindable} from 'app/common/gutil';
|
||||||
import {BindableValue, dom} from 'grainjs';
|
import {BindableValue, dom} from 'grainjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version of makeTestId that can be appended conditionally.
|
* Version of makeTestId that can be appended conditionally.
|
||||||
* TODO: update grainjs typings, as this is already supported there.
|
|
||||||
*/
|
*/
|
||||||
export function makeTestId(prefix: string) {
|
export function makeTestId(prefix: string) {
|
||||||
return (id: string, obs?: BindableValue<boolean>) => dom.cls(prefix + id, obs ?? true);
|
return (id: BindableValue<string>, obs?: BindableValue<boolean>) => {
|
||||||
|
return dom.cls(use => {
|
||||||
|
if (obs !== undefined && !useBindable(use, obs)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${useBindable(use, prefix)}${useBindable(use, id)}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -54,6 +54,16 @@ MetaRowModel.prototype._assignColumn = function(colName) {
|
|||||||
MetaRowModel.Floater = function(tableModel, rowIdObs) {
|
MetaRowModel.Floater = function(tableModel, rowIdObs) {
|
||||||
this._table = tableModel;
|
this._table = tableModel;
|
||||||
this.rowIdObs = rowIdObs;
|
this.rowIdObs = rowIdObs;
|
||||||
|
|
||||||
|
// Some tsc error prevents me from adding this at the module level.
|
||||||
|
// This method is part of the interface of MetaRowModel.
|
||||||
|
// TODO: Fix the tsc error and move this to the module level.
|
||||||
|
if (!this.constructor.prototype.getRowId) {
|
||||||
|
this.constructor.prototype.getRowId = function() {
|
||||||
|
return this.rowIdObs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Note that ._index isn't supported because it doesn't make sense for a floating row model.
|
// Note that ._index isn't supported because it doesn't make sense for a floating row model.
|
||||||
|
|
||||||
this._underlyingRowModel = this.autoDispose(ko.computed(function() {
|
this._underlyingRowModel = this.autoDispose(ko.computed(function() {
|
||||||
|
@ -17,7 +17,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
|
|||||||
|
|
||||||
widthPx: ko.Computed<string>;
|
widthPx: ko.Computed<string>;
|
||||||
column: ko.Computed<ColumnRec>;
|
column: ko.Computed<ColumnRec>;
|
||||||
|
origLabel: ko.Computed<string>;
|
||||||
origCol: ko.Computed<ColumnRec>;
|
origCol: ko.Computed<ColumnRec>;
|
||||||
|
pureType: ko.Computed<string>;
|
||||||
colId: ko.Computed<string>;
|
colId: ko.Computed<string>;
|
||||||
label: ko.Computed<string>;
|
label: ko.Computed<string>;
|
||||||
description: modelUtil.KoSaveableObservable<string>;
|
description: modelUtil.KoSaveableObservable<string>;
|
||||||
@ -101,6 +103,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
|
|||||||
// `formatter` formats actual cell values, e.g. a whole list from the display column.
|
// `formatter` formats actual cell values, e.g. a whole list from the display column.
|
||||||
formatter: ko.Computed<BaseFormatter>;
|
formatter: ko.Computed<BaseFormatter>;
|
||||||
|
|
||||||
|
/** Label in FormView. By default FormView uses label, use this to override it. */
|
||||||
|
question: modelUtil.KoSaveableObservable<string|undefined>;
|
||||||
|
|
||||||
createValueParser(): (value: string) => any;
|
createValueParser(): (value: string) => any;
|
||||||
|
|
||||||
// Helper which adds/removes/updates field's displayCol to match the formula.
|
// Helper which adds/removes/updates field's displayCol to match the formula.
|
||||||
@ -111,11 +116,13 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
|||||||
this.viewSection = refRecord(docModel.viewSections, this.parentId);
|
this.viewSection = refRecord(docModel.viewSections, this.parentId);
|
||||||
this.widthDef = modelUtil.fieldWithDefault(this.width, () => this.viewSection().defaultWidth());
|
this.widthDef = modelUtil.fieldWithDefault(this.width, () => this.viewSection().defaultWidth());
|
||||||
|
|
||||||
this.widthPx = ko.pureComputed(() => this.widthDef() + 'px');
|
this.widthPx = this.autoDispose(ko.pureComputed(() => this.widthDef() + 'px'));
|
||||||
this.column = refRecord(docModel.columns, this.colRef);
|
this.column = this.autoDispose(refRecord(docModel.columns, this.colRef));
|
||||||
this.origCol = ko.pureComputed(() => this.column().origCol());
|
this.origCol = this.autoDispose(ko.pureComputed(() => this.column().origCol()));
|
||||||
this.colId = ko.pureComputed(() => this.column().colId());
|
this.pureType = this.autoDispose(ko.pureComputed(() => this.column().pureType()));
|
||||||
this.label = ko.pureComputed(() => this.column().label());
|
this.colId = this.autoDispose(ko.pureComputed(() => this.column().colId()));
|
||||||
|
this.label = this.autoDispose(ko.pureComputed(() => this.column().label()));
|
||||||
|
this.origLabel = this.autoDispose(ko.pureComputed(() => this.origCol().label()));
|
||||||
this.description = modelUtil.savingComputed({
|
this.description = modelUtil.savingComputed({
|
||||||
read: () => this.column().description(),
|
read: () => this.column().description(),
|
||||||
write: (setter, val) => setter(this.column().description, val)
|
write: (setter, val) => setter(this.column().description, val)
|
||||||
@ -249,6 +256,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
|||||||
this.headerFontUnderline = this.widgetOptionsJson.prop('headerFontUnderline');
|
this.headerFontUnderline = this.widgetOptionsJson.prop('headerFontUnderline');
|
||||||
this.headerFontItalic = this.widgetOptionsJson.prop('headerFontItalic');
|
this.headerFontItalic = this.widgetOptionsJson.prop('headerFontItalic');
|
||||||
this.headerFontStrikethrough = this.widgetOptionsJson.prop('headerFontStrikethrough');
|
this.headerFontStrikethrough = this.widgetOptionsJson.prop('headerFontStrikethrough');
|
||||||
|
this.question = this.widgetOptionsJson.prop('question');
|
||||||
|
|
||||||
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
|
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
|
||||||
this.style = ko.pureComputed({
|
this.style = ko.pureComputed({
|
||||||
|
@ -37,6 +37,7 @@ import defaults = require('lodash/defaults');
|
|||||||
export interface InsertColOptions {
|
export interface InsertColOptions {
|
||||||
colInfo?: ColInfo;
|
colInfo?: ColInfo;
|
||||||
index?: number;
|
index?: number;
|
||||||
|
nestInActiveBundle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColInfo {
|
export interface ColInfo {
|
||||||
@ -54,6 +55,10 @@ export interface NewColInfo {
|
|||||||
colRef: number;
|
colRef: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NewFieldInfo extends NewColInfo {
|
||||||
|
fieldRef: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
|
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
|
||||||
// a grid section and a chart section).
|
// a grid section and a chart section).
|
||||||
export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner {
|
export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner {
|
||||||
@ -103,7 +108,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
|||||||
|
|
||||||
borderWidthPx: ko.Computed<string>;
|
borderWidthPx: ko.Computed<string>;
|
||||||
|
|
||||||
layoutSpecObj: modelUtil.ObjObservable<any>;
|
layoutSpecObj: modelUtil.SaveableObjObservable<any>;
|
||||||
|
|
||||||
_savedFilters: ko.Computed<KoArray<FilterRec>>;
|
_savedFilters: ko.Computed<KoArray<FilterRec>>;
|
||||||
|
|
||||||
@ -268,9 +273,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
|||||||
// Saves custom definition (bundles change)
|
// Saves custom definition (bundles change)
|
||||||
saveCustomDef(): Promise<void>;
|
saveCustomDef(): Promise<void>;
|
||||||
|
|
||||||
insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewColInfo>;
|
insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewFieldInfo>;
|
||||||
|
|
||||||
showColumn(colRef: number, index?: number): Promise<void>
|
showColumn(col: number|string, index?: number): Promise<number>
|
||||||
|
|
||||||
|
removeField(colRef: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WidgetMappedColumn = number|number[]|null;
|
export type WidgetMappedColumn = number|number[]|null;
|
||||||
@ -834,7 +841,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
...colInfo,
|
...colInfo,
|
||||||
'_position': parentPos,
|
'_position': parentPos,
|
||||||
}];
|
}];
|
||||||
let newColInfo: NewColInfo;
|
let newColInfo: NewFieldInfo;
|
||||||
await docModel.docData.bundleActions('Insert column', async () => {
|
await docModel.docData.bundleActions('Insert column', async () => {
|
||||||
newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);
|
newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);
|
||||||
if (!this.isRaw.peek() && !this.isRecordCard.peek()) {
|
if (!this.isRaw.peek() && !this.isRecordCard.peek()) {
|
||||||
@ -843,19 +850,28 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
parentId: this.id.peek(),
|
parentId: this.id.peek(),
|
||||||
parentPos,
|
parentPos,
|
||||||
};
|
};
|
||||||
await docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]);
|
const fieldRef = await docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]);
|
||||||
|
newColInfo.fieldRef = fieldRef;
|
||||||
}
|
}
|
||||||
});
|
}, {nestInActiveBundle: options.nestInActiveBundle});
|
||||||
return newColInfo!;
|
return newColInfo!;
|
||||||
};
|
};
|
||||||
|
|
||||||
this.showColumn = async (colRef: number, index = this.viewFields().peekLength) => {
|
this.showColumn = async (col: string|number, index = this.viewFields().peekLength) => {
|
||||||
const parentPos = fieldInsertPositions(this.viewFields(), index, 1)[0];
|
const parentPos = fieldInsertPositions(this.viewFields(), index, 1)[0];
|
||||||
|
const colRef = typeof col === 'string'
|
||||||
|
? this.table().columns().all().find(c => c.colId() === col)?.getRowId()
|
||||||
|
: col;
|
||||||
const colInfo = {
|
const colInfo = {
|
||||||
colRef,
|
colRef,
|
||||||
parentId: this.id.peek(),
|
parentId: this.id.peek(),
|
||||||
parentPos,
|
parentPos,
|
||||||
};
|
};
|
||||||
await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]);
|
return await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.removeField = async (fieldRef: number) => {
|
||||||
|
const action = ['RemoveRecord', fieldRef];
|
||||||
|
await docModel.viewFields.sendTableAction(action);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -33,3 +33,7 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
|
|||||||
}
|
}
|
||||||
return G.window.PERMITTED_CUSTOM_WIDGETS;
|
return G.window.PERMITTED_CUSTOM_WIDGETS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GRIST_FORMS_FEATURE() {
|
||||||
|
return Boolean(getGristConfig().experimentalPlugins);
|
||||||
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||||
import {autoGrow} from 'app/client/ui/forms';
|
import {autoGrow} from 'app/client/ui/forms';
|
||||||
import {textarea} from 'app/client/ui/inputs';
|
import {textarea, textInput} from 'app/client/ui/inputs';
|
||||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {CursorPos} from 'app/plugin/GristAPI';
|
import {CursorPos} from 'app/plugin/GristAPI';
|
||||||
import {dom, fromKo, MultiHolder, styled} from 'grainjs';
|
import {dom, DomArg, fromKo, MultiHolder, styled} from 'grainjs';
|
||||||
|
|
||||||
const t = makeT('DescriptionConfig');
|
const t = makeT('DescriptionConfig');
|
||||||
|
|
||||||
@ -46,6 +46,58 @@ export function buildDescriptionConfig(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic version of buildDescriptionConfig that can be used for any text input.
|
||||||
|
*/
|
||||||
|
export function buildTextInput(
|
||||||
|
owner: MultiHolder,
|
||||||
|
options: {
|
||||||
|
value: KoSaveableObservable<any>,
|
||||||
|
cursor: ko.Computed<CursorPos>,
|
||||||
|
label: string,
|
||||||
|
placeholder?: ko.Computed<string>,
|
||||||
|
},
|
||||||
|
...args: DomArg[]
|
||||||
|
) {
|
||||||
|
owner.autoDispose(
|
||||||
|
options.cursor.subscribe(() => {
|
||||||
|
options.value.save().catch(reportError);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
cssLabel(options.label),
|
||||||
|
cssRow(
|
||||||
|
cssTextInput(fromKo(options.value),
|
||||||
|
dom.on('blur', () => {
|
||||||
|
return options.value.save();
|
||||||
|
}),
|
||||||
|
dom.prop('placeholder', options.placeholder || ''),
|
||||||
|
...args
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssTextInput = styled(textInput, `
|
||||||
|
color: ${theme.inputFg};
|
||||||
|
background-color: ${theme.mainPanelBg};
|
||||||
|
border: 1px solid ${theme.inputBorder};
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0px 6px;
|
||||||
|
&::placeholder {
|
||||||
|
color: ${theme.inputPlaceholderFg};
|
||||||
|
}
|
||||||
|
|
||||||
|
&[readonly] {
|
||||||
|
background-color: ${theme.inputDisabledBg};
|
||||||
|
color: ${theme.inputDisabledFg};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
const cssTextArea = styled(textarea, `
|
const cssTextArea = styled(textarea, `
|
||||||
color: ${theme.inputFg};
|
color: ${theme.inputFg};
|
||||||
background-color: ${theme.mainPanelBg};
|
background-color: ${theme.mainPanelBg};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {allCommands} from 'app/client/components/commands';
|
import {allCommands} from 'app/client/components/commands';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import GridView from 'app/client/components/GridView';
|
import GridView from 'app/client/components/GridView';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {ColumnRec} from "app/client/models/entities/ColumnRec";
|
import {ColumnRec} from "app/client/models/entities/ColumnRec";
|
||||||
@ -44,6 +45,36 @@ export function buildAddColumnMenu(gridView: GridView, index?: number) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getColumnTypes(gristDoc: GristDoc, tableId: string, pure = false) {
|
||||||
|
const typeNames = [
|
||||||
|
"Text",
|
||||||
|
"Numeric",
|
||||||
|
"Int",
|
||||||
|
"Bool",
|
||||||
|
"Date",
|
||||||
|
`DateTime:${gristDoc.docModel.docInfoRow.timezone()}`,
|
||||||
|
"Choice",
|
||||||
|
"ChoiceList",
|
||||||
|
`Ref:${tableId}`,
|
||||||
|
`RefList:${tableId}`,
|
||||||
|
"Attachments"];
|
||||||
|
return typeNames.map(type => ({type, obj: UserType.typeDefs[type.split(':')[0]]}))
|
||||||
|
.map((ct): { displayName: string, colType: string, testIdName: string, icon: IconName | undefined } => ({
|
||||||
|
displayName: t(ct.obj.label),
|
||||||
|
colType: ct.type,
|
||||||
|
testIdName: ct.obj.label.toLowerCase().replace(' ', '-'),
|
||||||
|
icon: ct.obj.icon
|
||||||
|
})).map(ct => {
|
||||||
|
if (!pure) { return ct; }
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
...ct,
|
||||||
|
colType: ct.colType.split(':')[0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomElementArg[] {
|
function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomElementArg[] {
|
||||||
function buildEmptyNewColumMenuItem() {
|
function buildEmptyNewColumMenuItem() {
|
||||||
return menuItem(
|
return menuItem(
|
||||||
@ -56,24 +87,7 @@ function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomEle
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BuildNewColumnWithTypeSubmenu() {
|
function BuildNewColumnWithTypeSubmenu() {
|
||||||
const columnTypes = [
|
const columnTypes = getColumnTypes(gridView.gristDoc, gridView.tableModel.tableMetaRow.tableId());
|
||||||
"Text",
|
|
||||||
"Numeric",
|
|
||||||
"Int",
|
|
||||||
"Bool",
|
|
||||||
"Date",
|
|
||||||
`DateTime:${gridView.gristDoc.docModel.docInfoRow.timezone()}`,
|
|
||||||
"Choice",
|
|
||||||
"ChoiceList",
|
|
||||||
`Ref:${gridView.tableModel.tableMetaRow.tableId()}`,
|
|
||||||
`RefList:${gridView.tableModel.tableMetaRow.tableId()}`,
|
|
||||||
"Attachments"].map(type => ({type, obj: UserType.typeDefs[type.split(':')[0]]}))
|
|
||||||
.map((ct): { displayName: string, colType: string, testIdName: string, icon: IconName | undefined } => ({
|
|
||||||
displayName: t(ct.obj.label),
|
|
||||||
colType: ct.type,
|
|
||||||
testIdName: ct.obj.label.toLowerCase().replace(' ', '-'),
|
|
||||||
icon: ct.obj.icon
|
|
||||||
}));
|
|
||||||
|
|
||||||
return menuItemSubmenu(
|
return menuItemSubmenu(
|
||||||
(ctl) => [
|
(ctl) => [
|
||||||
|
@ -3,7 +3,7 @@ import {GristDoc} from 'app/client/components/GristDoc';
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {reportError} from 'app/client/models/AppModel';
|
import {reportError} from 'app/client/models/AppModel';
|
||||||
import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
|
import {GRIST_FORMS_FEATURE, PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
|
||||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||||
import {linkId, NoLink} from 'app/client/ui/selectBy';
|
import {linkId, NoLink} from 'app/client/ui/selectBy';
|
||||||
import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
|
import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
|
||||||
@ -35,7 +35,7 @@ import without = require('lodash/without');
|
|||||||
|
|
||||||
const t = makeT('PageWidgetPicker');
|
const t = makeT('PageWidgetPicker');
|
||||||
|
|
||||||
type TableId = number|'New Table'|null;
|
type TableRef = number|'New Table'|null;
|
||||||
|
|
||||||
// Describes a widget selection.
|
// Describes a widget selection.
|
||||||
export interface IPageWidget {
|
export interface IPageWidget {
|
||||||
@ -44,7 +44,7 @@ export interface IPageWidget {
|
|||||||
type: IWidgetType;
|
type: IWidgetType;
|
||||||
|
|
||||||
// The table (one of the listed tables or 'New Table')
|
// The table (one of the listed tables or 'New Table')
|
||||||
table: TableId;
|
table: TableRef;
|
||||||
|
|
||||||
// Whether to summarize the table (not available for "New Table").
|
// Whether to summarize the table (not available for "New Table").
|
||||||
summarize: boolean;
|
summarize: boolean;
|
||||||
@ -89,22 +89,26 @@ export interface IOptions extends ISelectOptions {
|
|||||||
|
|
||||||
const testId = makeTestId('test-wselect-');
|
const testId = makeTestId('test-wselect-');
|
||||||
|
|
||||||
|
function maybeForms(): Array<'form'> {
|
||||||
|
return GRIST_FORMS_FEATURE() ? ['form'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
// The picker disables some choices that do not make much sense. This function return the list of
|
// The picker disables some choices that do not make much sense. This function return the list of
|
||||||
// compatible types given the tableId and whether user is creating a new page or not.
|
// compatible types given the tableId and whether user is creating a new page or not.
|
||||||
function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] {
|
function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
|
||||||
if (tableId !== 'New Table') {
|
if (tableId !== 'New Table') {
|
||||||
return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar'];
|
return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', ...maybeForms()];
|
||||||
} else if (isNewPage) {
|
} else if (isNewPage) {
|
||||||
// New view + new table means we'll be switching to the primary view.
|
// New view + new table means we'll be switching to the primary view.
|
||||||
return ['record'];
|
return ['record', ...maybeForms()];
|
||||||
} else {
|
} else {
|
||||||
// The type 'chart' makes little sense when creating a new table.
|
// The type 'chart' makes little sense when creating a new table.
|
||||||
return ['record', 'single', 'detail'];
|
return ['record', 'single', 'detail', ...maybeForms()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether table and type make for a valid selection whether the user is creating a new page or not.
|
// Whether table and type make for a valid selection whether the user is creating a new page or not.
|
||||||
function isValidSelection(table: TableId, type: IWidgetType, isNewPage: boolean|undefined) {
|
function isValidSelection(table: TableRef, type: IWidgetType, isNewPage: boolean|undefined) {
|
||||||
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
|
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,7 +266,7 @@ const permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS
|
|||||||
const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=>
|
const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=>
|
||||||
registeredCustomWidgets.includes(a));
|
registeredCustomWidgets.includes(a));
|
||||||
const sectionTypes: IWidgetType[] = [
|
const sectionTypes: IWidgetType[] = [
|
||||||
'record', 'single', 'detail', 'chart', ...finalListOfCustomWidgetToShow, 'custom'
|
'record', 'single', 'detail', ...maybeForms(), 'chart', ...finalListOfCustomWidgetToShow, 'custom'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
@ -425,7 +429,7 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
this._value.type.set(type);
|
this._value.type.set(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _selectTable(tid: TableId) {
|
private _selectTable(tid: TableRef) {
|
||||||
if (tid !== this._value.table.get()) {
|
if (tid !== this._value.table.get()) {
|
||||||
this._value.link.set(NoLink);
|
this._value.link.set(NoLink);
|
||||||
}
|
}
|
||||||
@ -437,7 +441,7 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
return el.classList.contains(cssEntry.className + '-selected');
|
return el.classList.contains(cssEntry.className + '-selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
private _selectPivot(tid: TableId, pivotEl: HTMLElement) {
|
private _selectPivot(tid: TableRef, pivotEl: HTMLElement) {
|
||||||
if (this._isSelected(pivotEl)) {
|
if (this._isSelected(pivotEl)) {
|
||||||
this._closeSummarizePanel();
|
this._closeSummarizePanel();
|
||||||
} else {
|
} else {
|
||||||
@ -456,7 +460,7 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
this._value.columns.set(newIds);
|
this._value.columns.set(newIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _isTypeDisabled(type: IWidgetType, table: TableId) {
|
private _isTypeDisabled(type: IWidgetType, table: TableRef) {
|
||||||
if (table === null) {
|
if (table === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as commands from 'app/client/components/commands';
|
import * as commands from 'app/client/components/commands';
|
||||||
|
import {HiddenQuestionConfig} from 'app/client/components/Forms/HiddenQuestionConfig';
|
||||||
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
||||||
import {EmptyFilterState} from "app/client/components/LinkingState";
|
import {EmptyFilterState} from "app/client/components/LinkingState";
|
||||||
import {RefSelect} from 'app/client/components/RefSelect';
|
import {RefSelect} from 'app/client/components/RefSelect';
|
||||||
@ -26,7 +27,7 @@ import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs
|
|||||||
import {reportError} from 'app/client/models/AppModel';
|
import {reportError} from 'app/client/models/AppModel';
|
||||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||||
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
import {buildDescriptionConfig, buildTextInput} from 'app/client/ui/DescriptionConfig';
|
||||||
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
||||||
import {GridOptions} from 'app/client/ui/GridOptions';
|
import {GridOptions} from 'app/client/ui/GridOptions';
|
||||||
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||||
@ -263,6 +264,18 @@ export class RightPanel extends Disposable {
|
|||||||
// Builder for the reference display column multiselect.
|
// Builder for the reference display column multiselect.
|
||||||
const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder});
|
const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder});
|
||||||
|
|
||||||
|
// The original selected field model.
|
||||||
|
const fieldRef = owner.autoDispose(ko.pureComputed(() => {
|
||||||
|
return ((fieldBuilder()?.field)?.id()) ?? 0;
|
||||||
|
}));
|
||||||
|
const selectedField = owner.autoDispose(docModel.viewFields.createFloatingRowModel(fieldRef));
|
||||||
|
|
||||||
|
// For forms we will show some extra options.
|
||||||
|
const isForm = owner.autoDispose(ko.computed(() => {
|
||||||
|
const vs = this._gristDoc.viewModel.activeSection();
|
||||||
|
return vs.parentKey() === 'form';
|
||||||
|
}));
|
||||||
|
|
||||||
// build cursor position observable
|
// build cursor position observable
|
||||||
const cursor = owner.autoDispose(ko.computed(() => {
|
const cursor = owner.autoDispose(ko.computed(() => {
|
||||||
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
||||||
@ -276,6 +289,14 @@ export class RightPanel extends Disposable {
|
|||||||
cssSection(
|
cssSection(
|
||||||
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
|
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
|
||||||
),
|
),
|
||||||
|
dom.maybe(isForm, () => [
|
||||||
|
cssSection(
|
||||||
|
dom.create(buildTextInput, {
|
||||||
|
cursor, label: 'Question', value: selectedField.question,
|
||||||
|
placeholder: selectedField.origLabel
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
cssSection(
|
cssSection(
|
||||||
dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }),
|
dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }),
|
||||||
),
|
),
|
||||||
@ -430,6 +451,19 @@ export class RightPanel extends Disposable {
|
|||||||
|
|
||||||
cssSeparator(dom.hide(activeSection.isRecordCard)),
|
cssSeparator(dom.hide(activeSection.isRecordCard)),
|
||||||
|
|
||||||
|
dom.domComputed(use => {
|
||||||
|
const vs = use(activeSection.viewInstance);
|
||||||
|
if (!vs || use(activeSection.parentKey) !== 'form') { return null; }
|
||||||
|
return [
|
||||||
|
cssRow(
|
||||||
|
primaryButton(t("Reset form"), dom.on('click', () => {
|
||||||
|
activeSection.layoutSpecObj.setAndSave(null).catch(reportError);
|
||||||
|
})),
|
||||||
|
cssRow.cls('-top-space')
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
|
||||||
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
|
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
|
||||||
cssLabel(t("Theme")),
|
cssLabel(t("Theme")),
|
||||||
dom('div',
|
dom('div',
|
||||||
@ -486,11 +520,16 @@ export class RightPanel extends Disposable {
|
|||||||
use(hasCustomMapping) ||
|
use(hasCustomMapping) ||
|
||||||
use(this._pageWidgetType) === 'chart' ||
|
use(this._pageWidgetType) === 'chart' ||
|
||||||
use(activeSection.isRaw)
|
use(activeSection.isRaw)
|
||||||
),
|
) && use(activeSection.parentKey) !== 'form',
|
||||||
() => [
|
() => [
|
||||||
cssSeparator(),
|
cssSeparator(),
|
||||||
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
|
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
dom.maybe(use => use(activeSection.parentKey) === 'form', () => [
|
||||||
|
cssSeparator(),
|
||||||
|
dom.create(HiddenQuestionConfig, activeSection),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -276,14 +276,7 @@ export class VisibleFieldsConfig extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async removeField(field: IField) {
|
public async removeField(field: IField) {
|
||||||
const existing = this._section.viewFields.peek().peek()
|
await this._section.removeField(field.getRowId());
|
||||||
.find((f) => f.column.peek().getRowId() === field.origCol.peek().id.peek());
|
|
||||||
if (!existing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = existing.id.peek();
|
|
||||||
const action = ['RemoveRecord', id];
|
|
||||||
await this._gristDoc.docModel.viewFields.sendTableAction(action);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addField(column: IField, nextField: ViewFieldRec|null = null) {
|
public async addField(column: IField, nextField: ViewFieldRec|null = null) {
|
||||||
|
@ -39,9 +39,9 @@ export const cssInput = styled('input', `
|
|||||||
/**
|
/**
|
||||||
* Builds a text input that updates `obs` as you type.
|
* Builds a text input that updates `obs` as you type.
|
||||||
*/
|
*/
|
||||||
export function textInput(obs: Observable<string>, ...args: DomElementArg[]): HTMLInputElement {
|
export function textInput(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
|
||||||
return cssInput(
|
return cssInput(
|
||||||
dom.prop('value', obs),
|
dom.prop('value', u => u(obs) || ''),
|
||||||
dom.on('input', (_e, elem) => obs.set(elem.value)),
|
dom.on('input', (_e, elem) => obs.set(elem.value)),
|
||||||
...args,
|
...args,
|
||||||
);
|
);
|
||||||
|
@ -7,6 +7,7 @@ export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
|
|||||||
['single', {label: 'Card', icon: 'TypeCard'}],
|
['single', {label: 'Card', icon: 'TypeCard'}],
|
||||||
['detail', {label: 'Card List', icon: 'TypeCardList'}],
|
['detail', {label: 'Card List', icon: 'TypeCardList'}],
|
||||||
['chart', {label: 'Chart', icon: 'TypeChart'}],
|
['chart', {label: 'Chart', icon: 'TypeChart'}],
|
||||||
|
['form', {label: 'Form', icon: 'Board'}],
|
||||||
['custom', {label: 'Custom', icon: 'TypeCustom'}],
|
['custom', {label: 'Custom', icon: 'TypeCustom'}],
|
||||||
['custom.calendar', {label: 'Calendar', icon: 'TypeCalendar'}],
|
['custom.calendar', {label: 'Calendar', icon: 'TypeCalendar'}],
|
||||||
]);
|
]);
|
||||||
|
@ -56,10 +56,10 @@ export const colors = {
|
|||||||
darkBg: new CustomProp('color-dark-bg', '#262633'),
|
darkBg: new CustomProp('color-dark-bg', '#262633'),
|
||||||
slate: new CustomProp('color-slate', '#929299'),
|
slate: new CustomProp('color-slate', '#929299'),
|
||||||
|
|
||||||
|
lighterGreen: new CustomProp('color-lighter-green', '#b1ffe2'),
|
||||||
lightGreen: new CustomProp('color-light-green', '#16B378'),
|
lightGreen: new CustomProp('color-light-green', '#16B378'),
|
||||||
darkGreen: new CustomProp('color-dark-green', '#009058'),
|
darkGreen: new CustomProp('color-dark-green', '#009058'),
|
||||||
darkerGreen: new CustomProp('color-darker-green', '#007548'),
|
darkerGreen: new CustomProp('color-darker-green', '#007548'),
|
||||||
lighterGreen: new CustomProp('color-lighter-green', '#b1ffe2'),
|
|
||||||
|
|
||||||
lighterBlue: new CustomProp('color-lighter-blue', '#87b2f9'),
|
lighterBlue: new CustomProp('color-lighter-blue', '#87b2f9'),
|
||||||
lightBlue: new CustomProp('color-light-blue', '#3B82F6'),
|
lightBlue: new CustomProp('color-light-blue', '#3B82F6'),
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Command } from 'app/client/components/commands';
|
import { MenuCommand } from 'app/client/components/commandList';
|
||||||
import { FocusLayer } from 'app/client/lib/FocusLayer';
|
import { FocusLayer } from 'app/client/lib/FocusLayer';
|
||||||
import { makeT } from 'app/client/lib/localization';
|
import { makeT } from 'app/client/lib/localization';
|
||||||
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
|
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
|
||||||
@ -575,7 +575,7 @@ export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function menuItemCmd(
|
export function menuItemCmd(
|
||||||
cmd: Command,
|
cmd: MenuCommand,
|
||||||
label: string | (() => DomContents),
|
label: string | (() => DomContents),
|
||||||
...args: DomElementArg[]
|
...args: DomElementArg[]
|
||||||
) {
|
) {
|
||||||
@ -787,8 +787,9 @@ const cssUpgradeTextButton = styled(textButton, `
|
|||||||
|
|
||||||
const cssMenuItemSubmenu = styled('div', `
|
const cssMenuItemSubmenu = styled('div', `
|
||||||
position: relative;
|
position: relative;
|
||||||
|
justify-content: flex-start;
|
||||||
color: ${theme.menuItemFg};
|
color: ${theme.menuItemFg};
|
||||||
--icon-color: ${theme.menuItemFg};
|
--icon-color: ${theme.accentIcon};
|
||||||
.${weasel.cssMenuItem.className}-sel {
|
.${weasel.cssMenuItem.className}-sel {
|
||||||
color: ${theme.menuItemSelectedFg};
|
color: ${theme.menuItemSelectedFg};
|
||||||
--icon-color: ${theme.menuItemSelectedFg};
|
--icon-color: ${theme.menuItemSelectedFg};
|
||||||
|
226
app/common/Forms.ts
Normal file
226
app/common/Forms.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import {GristType} from 'app/plugin/GristData';
|
||||||
|
import {marked} from 'marked';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All allowed boxes.
|
||||||
|
*/
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML.
|
||||||
|
*/
|
||||||
|
export interface RenderContext {
|
||||||
|
field(id: number): FieldModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldModel {
|
||||||
|
question: string;
|
||||||
|
description: string;
|
||||||
|
colId: string;
|
||||||
|
type: string;
|
||||||
|
options: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The RenderBox is the main building block for the form. Each main block has its own, and is responsible for
|
||||||
|
* rendering itself and its children.
|
||||||
|
*/
|
||||||
|
export class RenderBox {
|
||||||
|
public static new(box: Box, ctx: RenderContext): RenderBox {
|
||||||
|
console.assert(box, `Box is not defined`);
|
||||||
|
const ctr = elements[box.type];
|
||||||
|
console.assert(ctr, `Box ${box.type} is not defined`);
|
||||||
|
return new ctr(box, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(protected box: Box, protected ctx: RenderContext) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public toHTML(): string {
|
||||||
|
return (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Paragraph extends RenderBox {
|
||||||
|
public override toHTML(): string {
|
||||||
|
const text = this.box['text'] || '**Lorem** _ipsum_ dolor';
|
||||||
|
const html = marked(text);
|
||||||
|
return `
|
||||||
|
<div class="grist-paragraph">${html}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Section extends RenderBox {
|
||||||
|
/** Nothing, default is enough */
|
||||||
|
}
|
||||||
|
|
||||||
|
class Columns extends RenderBox {
|
||||||
|
public override toHTML(): string {
|
||||||
|
const kids = this.box.children || [];
|
||||||
|
return `
|
||||||
|
<div class="grist-columns" style='--grist-columns-count: ${kids.length}'>
|
||||||
|
${kids.map((child) => child.toHTML()).join('\n')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Submit extends RenderBox {
|
||||||
|
public override toHTML() {
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<input type='submit' value='Submit' />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Placeholder extends RenderBox {
|
||||||
|
public override toHTML() {
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Layout extends RenderBox {
|
||||||
|
/** Nothing, default is enough */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field is a special kind of box, as it renders a Grist field (a Question). It provides a default frame, like label and
|
||||||
|
* description, and then renders the field itself in same way as the main Boxes where rendered.
|
||||||
|
*/
|
||||||
|
class Field extends RenderBox {
|
||||||
|
|
||||||
|
public static render(field: FieldModel, context: RenderContext): string {
|
||||||
|
const ctr = (questions as any)[field.type as any] as { new(): Question } || Text;
|
||||||
|
return new ctr().toHTML(field, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toHTML(): string {
|
||||||
|
const field = this.ctx.field(this.box['leaf']);
|
||||||
|
if (!field) {
|
||||||
|
return `<div class="grist-field">Field not found</div>`;
|
||||||
|
}
|
||||||
|
const label = field.question ? field.question : field.colId;
|
||||||
|
const name = field.colId;
|
||||||
|
let description = field.description || '';
|
||||||
|
if (description) {
|
||||||
|
description = `<div class='grist-field-description'>${description}</div>`;
|
||||||
|
}
|
||||||
|
const html = `<div class='grist-field-content'>${Field.render(field, this.ctx)}</div>`;
|
||||||
|
return `
|
||||||
|
<div class="grist-field">
|
||||||
|
<label for='${name}'>${label}</label>
|
||||||
|
${html}
|
||||||
|
${description}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
toHTML(field: FieldModel, context: RenderContext): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Text implements Question {
|
||||||
|
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||||
|
return `
|
||||||
|
<input type='text' name='${field.colId}' />
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Date implements Question {
|
||||||
|
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||||
|
return `
|
||||||
|
<input type='date' name='${field.colId}' />
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateTime implements Question {
|
||||||
|
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||||
|
return `
|
||||||
|
<input type='datetime-local' name='${field.colId}' />
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Choice implements Question {
|
||||||
|
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||||
|
const choices: string[] = field.options.choices || [];
|
||||||
|
return `
|
||||||
|
<select name='${field.colId}'>
|
||||||
|
${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Bool implements Question {
|
||||||
|
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||||
|
return `
|
||||||
|
<label>
|
||||||
|
<input type='checkbox' name='${field.colId}' value="1" />
|
||||||
|
Yes
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChoiceList implements Question {
|
||||||
|
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||||
|
const choices: string[] = field.options.choices || [];
|
||||||
|
return `
|
||||||
|
<div name='${field.colId}' class='grist-choice-list'>
|
||||||
|
${choices.map((choice) => `
|
||||||
|
<label>
|
||||||
|
<input type='checkbox' name='${field.colId}[]' value='${choice}' />
|
||||||
|
${choice}
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of all available questions we will render of the form.
|
||||||
|
* TODO: add other renderers.
|
||||||
|
*/
|
||||||
|
const questions: Partial<Record<GristType, new () => Question>> = {
|
||||||
|
'Text': Text,
|
||||||
|
'Choice': Choice,
|
||||||
|
'Bool': Bool,
|
||||||
|
'ChoiceList': ChoiceList,
|
||||||
|
'Date': Date,
|
||||||
|
'DateTime': DateTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of all available boxes we will render of the form.
|
||||||
|
*/
|
||||||
|
const elements = {
|
||||||
|
'Paragraph': Paragraph,
|
||||||
|
'Section': Section,
|
||||||
|
'Columns': Columns,
|
||||||
|
'Submit': Submit,
|
||||||
|
'Placeholder': Placeholder,
|
||||||
|
'Layout': Layout,
|
||||||
|
'Field': Field,
|
||||||
|
};
|
@ -419,6 +419,10 @@ export interface UserAPI {
|
|||||||
* is specific to Grist installation, and might not be supported.
|
* is specific to Grist installation, and might not be supported.
|
||||||
*/
|
*/
|
||||||
closeOrg(): Promise<void>;
|
closeOrg(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Creates publicly shared URL for a rendered form.
|
||||||
|
*/
|
||||||
|
formUrl(docId: string, vsId: number): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -510,6 +514,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
super(_options);
|
super(_options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public formUrl(docId: string, vsId: number): string {
|
||||||
|
return `${this._url}/api/docs/${docId}/forms/${vsId}`;
|
||||||
|
}
|
||||||
|
|
||||||
public forRemoved(): UserAPI {
|
public forRemoved(): UserAPI {
|
||||||
const extraParameters = new Map<string, string>([['showRemoved', '1']]);
|
const extraParameters = new Map<string, string>([['showRemoved', '1']]);
|
||||||
return new UserAPIImpl(this._homeUrl, {...this._options, extraParameters});
|
return new UserAPIImpl(this._homeUrl, {...this._options, extraParameters});
|
||||||
|
@ -1,5 +1,18 @@
|
|||||||
import {BindableValue, DomElementMethod, IKnockoutReadObservable, ISubscribable, Listener, Observable,
|
import {
|
||||||
subscribeElem, UseCB, UseCBOwner} from 'grainjs';
|
BindableValue,
|
||||||
|
Computed,
|
||||||
|
DomElementMethod,
|
||||||
|
Holder,
|
||||||
|
IDisposableOwner,
|
||||||
|
IKnockoutReadObservable,
|
||||||
|
ISubscribable,
|
||||||
|
Listener,
|
||||||
|
MultiHolder,
|
||||||
|
Observable,
|
||||||
|
subscribeElem,
|
||||||
|
UseCB,
|
||||||
|
UseCBOwner
|
||||||
|
} from 'grainjs';
|
||||||
import {Observable as KoObservable} from 'knockout';
|
import {Observable as KoObservable} from 'knockout';
|
||||||
import identity = require('lodash/identity');
|
import identity = require('lodash/identity');
|
||||||
|
|
||||||
@ -827,9 +840,9 @@ export async function waitGrainObs<T>(observable: Observable<T>,
|
|||||||
// `dom.style` does not work here because custom css property (ie: `--foo`) needs to be set using
|
// `dom.style` does not work here because custom css property (ie: `--foo`) needs to be set using
|
||||||
// `style.setProperty` (credit: https://vanseodesign.com/css/custom-properties-and-javascript/).
|
// `style.setProperty` (credit: https://vanseodesign.com/css/custom-properties-and-javascript/).
|
||||||
// TODO: consider making PR to fix `dom.style` in grainjs.
|
// TODO: consider making PR to fix `dom.style` in grainjs.
|
||||||
export function inlineStyle(property: string, valueObs: BindableValue<string>): DomElementMethod {
|
export function inlineStyle(property: string, valueObs: BindableValue<any>): DomElementMethod {
|
||||||
return (elem) => subscribeElem(elem, valueObs, (val) => {
|
return (elem) => subscribeElem(elem, valueObs, (val) => {
|
||||||
elem.style.setProperty(property, val);
|
elem.style.setProperty(property, String(val ?? ''));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -950,6 +963,24 @@ export const unwrap: UseCB = (obs: ISubscribable) => {
|
|||||||
return (obs as ko.Observable).peek();
|
return (obs as ko.Observable).peek();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to BindableValue
|
||||||
|
*/
|
||||||
|
export function useBindable<T>(use: UseCBOwner, obs: BindableValue<T>): T {
|
||||||
|
if (obs === null || obs === undefined) { return obs; }
|
||||||
|
|
||||||
|
const smth = obs as any;
|
||||||
|
|
||||||
|
// If knockout
|
||||||
|
if (typeof smth === 'function' && 'peek' in smth) { return use(smth) as T; }
|
||||||
|
// If grainjs Observable or Computed
|
||||||
|
if (typeof smth === 'object' && '_getDepItem' in smth) { return use(smth) as T; }
|
||||||
|
// If use function ComputedCallback
|
||||||
|
if (typeof smth === 'function') { return smth(use) as T; }
|
||||||
|
|
||||||
|
return obs as T;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use helper for simple boolean negation.
|
* Use helper for simple boolean negation.
|
||||||
*/
|
*/
|
||||||
@ -1006,3 +1037,20 @@ export function notSet(value: any) {
|
|||||||
export function ifNotSet(value: any, def: any = null) {
|
export function ifNotSet(value: any, def: any = null) {
|
||||||
return notSet(value) ? def : value;
|
return notSet(value) ? def : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a computed observable with a nested owner that can be used to dispose,
|
||||||
|
* any disposables created inside the computed. Similar to domComputedOwned method.
|
||||||
|
*/
|
||||||
|
export function computedOwned<T>(
|
||||||
|
owner: IDisposableOwner,
|
||||||
|
func: (owner: IDisposableOwner, use: UseCBOwner) => T
|
||||||
|
): Computed<T> {
|
||||||
|
const holder = Holder.create(owner);
|
||||||
|
return Computed.create(owner, use => {
|
||||||
|
const computedOwner = MultiHolder.create(holder);
|
||||||
|
return func(computedOwner, use);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Constructor<T> = new (...args: any[]) => T;
|
||||||
|
@ -8,4 +8,4 @@ export const AttachedCustomWidgets = StringUnion('custom.calendar');
|
|||||||
export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;
|
export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;
|
||||||
|
|
||||||
// all widget types
|
// all widget types
|
||||||
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | IAttachedCustomWidget;
|
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | 'form' | IAttachedCustomWidget;
|
||||||
|
@ -62,6 +62,7 @@ export class DocApiForwarder {
|
|||||||
app.use('/api/docs/:docId/webhooks', withDoc);
|
app.use('/api/docs/:docId/webhooks', withDoc);
|
||||||
app.use('/api/docs/:docId/assistant', withDoc);
|
app.use('/api/docs/:docId/assistant', withDoc);
|
||||||
app.use('/api/docs/:docId/sql', withDoc);
|
app.use('/api/docs/:docId/sql', withDoc);
|
||||||
|
app.use('/api/docs/:docId/forms/:id', withDoc);
|
||||||
app.use('^/api/docs$', withoutDoc);
|
app.use('^/api/docs$', withoutDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer";
|
import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer";
|
||||||
import {createEmptyActionSummary} from "app/common/ActionSummary";
|
import {createEmptyActionSummary} from "app/common/ActionSummary";
|
||||||
|
import {QueryFilters} from 'app/common/ActiveDocAPI';
|
||||||
import {ApiError, LimitType} from 'app/common/ApiError';
|
import {ApiError, LimitType} from 'app/common/ApiError';
|
||||||
import {BrowserSettings} from "app/common/BrowserSettings";
|
import {BrowserSettings} from "app/common/BrowserSettings";
|
||||||
import {
|
import {
|
||||||
@ -11,8 +12,9 @@ import {
|
|||||||
UserAction
|
UserAction
|
||||||
} from 'app/common/DocActions';
|
} from 'app/common/DocActions';
|
||||||
import {isRaisedException} from "app/common/gristTypes";
|
import {isRaisedException} from "app/common/gristTypes";
|
||||||
|
import {Box, RenderBox, RenderContext} from "app/common/Forms";
|
||||||
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
|
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
|
||||||
import {isAffirmative, timeoutReached} from "app/common/gutil";
|
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
|
||||||
import {SchemaTypes} from "app/common/schema";
|
import {SchemaTypes} from "app/common/schema";
|
||||||
import {SortFunc} from 'app/common/SortFunc';
|
import {SortFunc} from 'app/common/SortFunc';
|
||||||
import {Sort} from 'app/common/SortSpec';
|
import {Sort} from 'app/common/SortSpec';
|
||||||
@ -60,6 +62,7 @@ import {GristServer} from 'app/server/lib/GristServer';
|
|||||||
import {HashUtil} from 'app/server/lib/HashUtil';
|
import {HashUtil} from 'app/server/lib/HashUtil';
|
||||||
import {makeForkIds} from "app/server/lib/idUtils";
|
import {makeForkIds} from "app/server/lib/idUtils";
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
|
import {getAppPathTo} from 'app/server/lib/places';
|
||||||
import {
|
import {
|
||||||
getDocId,
|
getDocId,
|
||||||
getDocScope,
|
getDocScope,
|
||||||
@ -81,9 +84,11 @@ import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload,
|
|||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import contentDisposition from 'content-disposition';
|
import contentDisposition from 'content-disposition';
|
||||||
import {Application, NextFunction, Request, RequestHandler, Response} from "express";
|
import {Application, NextFunction, Request, RequestHandler, Response} from "express";
|
||||||
|
import jsesc from 'jsesc';
|
||||||
import * as _ from "lodash";
|
import * as _ from "lodash";
|
||||||
import LRUCache from 'lru-cache';
|
import LRUCache from 'lru-cache';
|
||||||
import * as moment from 'moment';
|
import * as moment from 'moment';
|
||||||
|
import * as fse from 'fs-extra';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as t from "ts-interface-checker";
|
import * as t from "ts-interface-checker";
|
||||||
@ -163,7 +168,8 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
constructor(private _app: Application, private _docWorker: DocWorker,
|
constructor(private _app: Application, private _docWorker: DocWorker,
|
||||||
private _docWorkerMap: IDocWorkerMap, private _docManager: DocManager,
|
private _docWorkerMap: IDocWorkerMap, private _docManager: DocManager,
|
||||||
private _dbManager: HomeDBManager, private _grist: GristServer) {}
|
private _dbManager: HomeDBManager, private _grist: GristServer,
|
||||||
|
private _staticPath: string) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds endpoints for the doc api.
|
* Adds endpoints for the doc api.
|
||||||
@ -215,14 +221,18 @@ export class DocWorkerApi {
|
|||||||
res.json(await activeDoc.applyUserActions(docSessionFromRequest(req), req.body, {parseStrings}));
|
res.json(await activeDoc.applyUserActions(docSessionFromRequest(req), req.body, {parseStrings}));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function getTableData(activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string) {
|
|
||||||
const filters = req.query.filter ? JSON.parse(String(req.query.filter)) : {};
|
async function readTable(
|
||||||
|
req: RequestWithLogin,
|
||||||
|
activeDoc: ActiveDoc,
|
||||||
|
tableId: string,
|
||||||
|
filters: QueryFilters,
|
||||||
|
params: QueryParameters & {immediate?: boolean}) {
|
||||||
// Option to skip waiting for document initialization.
|
// Option to skip waiting for document initialization.
|
||||||
const immediate = isAffirmative(req.query.immediate);
|
const immediate = isAffirmative(params.immediate);
|
||||||
if (!Object.keys(filters).every(col => Array.isArray(filters[col]))) {
|
if (!Object.keys(filters).every(col => Array.isArray(filters[col]))) {
|
||||||
throw new ApiError("Invalid query: filter values must be arrays", 400);
|
throw new ApiError("Invalid query: filter values must be arrays", 400);
|
||||||
}
|
}
|
||||||
const tableId = await getRealTableId(optTableId || req.params.tableId, {activeDoc, req});
|
|
||||||
const session = docSessionFromRequest(req);
|
const session = docSessionFromRequest(req);
|
||||||
const {tableData} = await handleSandboxError(tableId, [], activeDoc.fetchQuery(
|
const {tableData} = await handleSandboxError(tableId, [], activeDoc.fetchQuery(
|
||||||
session, {tableId, filters}, !immediate));
|
session, {tableId, filters}, !immediate));
|
||||||
@ -230,16 +240,22 @@ export class DocWorkerApi {
|
|||||||
const isMetaTable = tableId.startsWith('_grist');
|
const isMetaTable = tableId.startsWith('_grist');
|
||||||
const columns = isMetaTable ? null :
|
const columns = isMetaTable ? null :
|
||||||
await handleSandboxError('', [], activeDoc.getTableCols(session, tableId, true));
|
await handleSandboxError('', [], activeDoc.getTableCols(session, tableId, true));
|
||||||
const params = getQueryParameters(req);
|
|
||||||
// Apply sort/limit parameters, if set. TODO: move sorting/limiting into data engine
|
// Apply sort/limit parameters, if set. TODO: move sorting/limiting into data engine
|
||||||
// and sql.
|
// and sql.
|
||||||
return applyQueryParameters(fromTableDataAction(tableData), params, columns);
|
return applyQueryParameters(fromTableDataAction(tableData), params, columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTableRecords(
|
async function getTableData(activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string) {
|
||||||
activeDoc: ActiveDoc, req: RequestWithLogin, opts?: { optTableId?: string; includeHidden?: boolean }
|
const filters = req.query.filter ? JSON.parse(String(req.query.filter)) : {};
|
||||||
): Promise<TableRecordValue[]> {
|
// Option to skip waiting for document initialization.
|
||||||
const columnData = await getTableData(activeDoc, req, opts?.optTableId);
|
const immediate = isAffirmative(req.query.immediate);
|
||||||
|
const tableId = await getRealTableId(optTableId || req.params.tableId, {activeDoc, req});
|
||||||
|
const params = getQueryParameters(req);
|
||||||
|
return await readTable(req, activeDoc, tableId, filters, {...params, immediate});
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecords(
|
||||||
|
columnData: TableColValues, opts?: { optTableId?: string; includeHidden?: boolean }): TableRecordValue[] {
|
||||||
const fieldNames = Object.keys(columnData).filter((k) => {
|
const fieldNames = Object.keys(columnData).filter((k) => {
|
||||||
if (k === "id") {
|
if (k === "id") {
|
||||||
return false;
|
return false;
|
||||||
@ -266,6 +282,13 @@ export class DocWorkerApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getTableRecords(
|
||||||
|
activeDoc: ActiveDoc, req: RequestWithLogin, opts?: { optTableId?: string; includeHidden?: boolean }
|
||||||
|
): Promise<TableRecordValue[]> {
|
||||||
|
const columnData = await getTableData(activeDoc, req, opts?.optTableId);
|
||||||
|
return asRecords(columnData, opts);
|
||||||
|
}
|
||||||
|
|
||||||
// Get the specified table in column-oriented format
|
// Get the specified table in column-oriented format
|
||||||
this._app.get('/api/docs/:docId/tables/:tableId/data', canView,
|
this._app.get('/api/docs/:docId/tables/:tableId/data', canView,
|
||||||
withDoc(async (activeDoc, req, res) => {
|
withDoc(async (activeDoc, req, res) => {
|
||||||
@ -1343,6 +1366,99 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
return res.status(200).json(docId);
|
return res.status(200).json(docId);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Get the specified table in record-oriented format
|
||||||
|
this._app.get('/api/docs/:docId/forms/:id', canView,
|
||||||
|
withDoc(async (activeDoc, req, res) => {
|
||||||
|
// Get the viewSection record for the specified id.
|
||||||
|
const id = integerParam(req.params.id, 'id');
|
||||||
|
const records = asRecords(await readTable(
|
||||||
|
req, activeDoc, '_grist_Views_section', { id: [id] }, { }
|
||||||
|
));
|
||||||
|
const vs = records.find(r => r.id === id);
|
||||||
|
if (!vs) {
|
||||||
|
throw new ApiError(`ViewSection ${id} not found`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the context that will be needed for rendering this form.
|
||||||
|
const fields = asRecords(await readTable(
|
||||||
|
req, activeDoc, '_grist_Views_section_field', { parentId: [id] }, { }
|
||||||
|
));
|
||||||
|
const cols = asRecords(await readTable(
|
||||||
|
req, activeDoc, '_grist_Tables_column', { parentId: [vs.fields.tableRef] }, { }
|
||||||
|
));
|
||||||
|
|
||||||
|
// Read the box specs
|
||||||
|
const spec = vs.fields.layoutSpec;
|
||||||
|
let box: Box = safeJsonParse(spec ? String(spec) : '', null);
|
||||||
|
if (!box) {
|
||||||
|
const editable = fields.filter(f => {
|
||||||
|
const col = cols.find(c => c.id === f.fields.colRef);
|
||||||
|
// Can't do attachments and formulas.
|
||||||
|
return col && !(col.fields.isFormula && col.fields.formula) && col.fields.type !== 'Attachment';
|
||||||
|
});
|
||||||
|
box = {
|
||||||
|
type: 'Layout',
|
||||||
|
children: editable.map(f => ({
|
||||||
|
type: 'Field',
|
||||||
|
leaf: f.id
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
box.children!.push({
|
||||||
|
type: 'Submit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: RenderContext = {
|
||||||
|
field(fieldRef: number) {
|
||||||
|
const field = fields.find(f => f.id === fieldRef);
|
||||||
|
if (!field) { throw new Error(`Field ${fieldRef} not found`); }
|
||||||
|
const col = cols.find(c => c.id === field.fields.colRef);
|
||||||
|
if (!col) { throw new Error(`Column ${field.fields.colRef} not found`); }
|
||||||
|
const fieldOptions = safeJsonParse(field.fields.widgetOptions as string, {});
|
||||||
|
const colOptions = safeJsonParse(col.fields.widgetOptions as string, {});
|
||||||
|
const options = {...colOptions, ...fieldOptions};
|
||||||
|
return {
|
||||||
|
colId: col.fields.colId as string,
|
||||||
|
description: options.description,
|
||||||
|
question: options.question,
|
||||||
|
type: (col.fields.type as string).split(':')[0],
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Now render the box to HTML.
|
||||||
|
const html = RenderBox.new(box, context).toHTML();
|
||||||
|
|
||||||
|
// The html will be inserted into a form as a replacement for:
|
||||||
|
// document.write(sanitize(`<!-- INSERT CONTENT -->`))
|
||||||
|
// We need to properly escape `
|
||||||
|
const escaped = jsesc(html, {isScriptContext: true, quotes: 'backtick'});
|
||||||
|
// And wrap it with the form template.
|
||||||
|
const form = await fse.readFile(path.join(getAppPathTo(this._staticPath, 'static'),
|
||||||
|
'forms/form.html'), 'utf8');
|
||||||
|
// TODO: externalize css. Currently the redirect mechanism depends on the relative base URL, so
|
||||||
|
// we can't change it at this moment. But once custom success page will be implemented this should
|
||||||
|
// be possible.
|
||||||
|
|
||||||
|
const staticOrigin = process.env.APP_STATIC_URL || "";
|
||||||
|
const staticBaseUrl = `${staticOrigin}/v/${this._grist.getTag()}/`;
|
||||||
|
// Fill out the blanks and send the result.
|
||||||
|
const doc = await this._dbManager.getDoc(req);
|
||||||
|
const docUrl = await this._grist.getResourceUrl(doc, 'html');
|
||||||
|
const tableId = await getRealTableId(String(vs.fields.tableRef), {activeDoc, req});
|
||||||
|
res.status(200).send(form
|
||||||
|
.replace('<!-- INSERT CONTENT -->', escaped || '')
|
||||||
|
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">`)
|
||||||
|
.replace('<!-- INSERT DOC URL -->', docUrl)
|
||||||
|
.replace('<!-- INSERT TABLE ID -->', tableId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the HTML if it exists, otherwise return 404.
|
||||||
|
res.send(html);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _copyDocToWorkspace(req: Request, options: {
|
private async _copyDocToWorkspace(req: Request, options: {
|
||||||
@ -1877,9 +1993,9 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
export function addDocApiRoutes(
|
export function addDocApiRoutes(
|
||||||
app: Application, docWorker: DocWorker, docWorkerMap: IDocWorkerMap, docManager: DocManager, dbManager: HomeDBManager,
|
app: Application, docWorker: DocWorker, docWorkerMap: IDocWorkerMap, docManager: DocManager, dbManager: HomeDBManager,
|
||||||
grist: GristServer
|
grist: GristServer, staticPath: string
|
||||||
) {
|
) {
|
||||||
const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist);
|
const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist, staticPath);
|
||||||
api.addEndpoints();
|
api.addEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1281,7 +1281,7 @@ export class FlexServer implements GristServer {
|
|||||||
this._addSupportPaths(docAccessMiddleware);
|
this._addSupportPaths(docAccessMiddleware);
|
||||||
|
|
||||||
if (!isSingleUserMode()) {
|
if (!isSingleUserMode()) {
|
||||||
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this);
|
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this, this.appRoot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,7 +152,6 @@
|
|||||||
"i18next": "21.9.1",
|
"i18next": "21.9.1",
|
||||||
"i18next-http-middleware": "3.3.2",
|
"i18next-http-middleware": "3.3.2",
|
||||||
"image-size": "0.6.3",
|
"image-size": "0.6.3",
|
||||||
"isomorphic-dompurify": "1.11.0",
|
|
||||||
"jquery": "3.5.0",
|
"jquery": "3.5.0",
|
||||||
"js-yaml": "3.14.1",
|
"js-yaml": "3.14.1",
|
||||||
"jsdom": "^23.0.0",
|
"jsdom": "^23.0.0",
|
||||||
|
156
static/forms/form.html
Normal file
156
static/forms/form.html
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8">
|
||||||
|
<!-- INSERT BASE -->
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
line-height: 1.42857143;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="forms/grist-form-submit.js"></script>
|
||||||
|
<script src="forms/purify.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.grist-form-container {
|
||||||
|
color: #262633;
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
min-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 52px;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||||
|
}
|
||||||
|
|
||||||
|
.grist-form-container .grist-form-confirm {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form {
|
||||||
|
padding: 32px;
|
||||||
|
margin: 0px auto;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #E8E8E8;
|
||||||
|
width: 640px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form .grist-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form .grist-field label {
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form .grist-field .grist-field-description {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #929299;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form .grist-field input[type="text"] {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #D9D9D9;
|
||||||
|
font-size: 13px;
|
||||||
|
outline-color: #16b378;
|
||||||
|
outline-width: 1px;
|
||||||
|
line-height: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form input[type="submit"] {
|
||||||
|
background-color: #16b378;
|
||||||
|
border: 1px solid #16b378;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form input[type="datetime-local"] {
|
||||||
|
width: 100%;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form input[type="date"] {
|
||||||
|
width: 100%;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form input[type="submit"]:hover {
|
||||||
|
border-color: #009058;
|
||||||
|
background-color: #009058;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form input[type="checkbox"] {
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form .grist-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #D9D9D9;
|
||||||
|
font-size: 13px;
|
||||||
|
outline-color: #16b378;
|
||||||
|
outline-width: 1px;
|
||||||
|
background: white;
|
||||||
|
line-height: inherit;
|
||||||
|
flex: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.grist-form .grist-choice-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main class='grist-form-container'>
|
||||||
|
<form class='grist-form'
|
||||||
|
onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'block', event.target.style.display = 'none'"
|
||||||
|
data-grist-doc="<!-- INSERT DOC URL -->"
|
||||||
|
data-grist-table="<!-- INSERT TABLE ID -->">
|
||||||
|
<script>
|
||||||
|
document.write(DOMPurify.sanitize(`<!-- INSERT CONTENT -->`));
|
||||||
|
</script>
|
||||||
|
</form>
|
||||||
|
<div class='grist-form-confirm' style='display: none'>
|
||||||
|
Thank you! Your response has been recorded.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
169
static/forms/grist-form-submit.js
Normal file
169
static/forms/grist-form-submit.js
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
// If the script is loaded multiple times, only register the handlers once.
|
||||||
|
if (!window.gristFormSubmit) {
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gristFormSubmit(gristDocUrl, gristTableId, formData)
|
||||||
|
* - `gristDocUrl` should be the URL of the Grist document, from step 1 of the setup instructions.
|
||||||
|
* - `gristTableId` should be the table ID from step 2.
|
||||||
|
* - `formData` should be a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||||
|
* object, typically obtained as `new FormData(formElement)`. Inside the `submit` event handler, it
|
||||||
|
* can be convenient to use `new FormData(event.target)`.
|
||||||
|
*
|
||||||
|
* This function sends values from `formData` to add a new record in the specified Grist table. It
|
||||||
|
* returns a promise for the result of the add-record API call. In case of an error, the promise
|
||||||
|
* will be rejected with an error message.
|
||||||
|
*/
|
||||||
|
async function gristFormSubmit(docUrl, tableId, formData) {
|
||||||
|
// Pick out the server and docId from the docUrl.
|
||||||
|
const match = /^(https?:\/\/[^\/]+(?:\/o\/[^\/]+)?)\/(?:doc\/([^\/?#]+)|([^\/?#]{12,})\/)/.exec(docUrl);
|
||||||
|
if (!match) { throw new Error("Invalid Grist doc URL " + docUrl); }
|
||||||
|
const server = match[1];
|
||||||
|
const docId = match[2] || match[3];
|
||||||
|
|
||||||
|
// Construct the URL to use for the add-record API endpoint.
|
||||||
|
const destUrl = server + "/api/docs/" + docId + "/tables/" + tableId + "/records";
|
||||||
|
|
||||||
|
const payload = {records: [{fields: formDataToJson(formData)}]};
|
||||||
|
const options = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await window.fetch(destUrl, options);
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
// Try to report a helpful error.
|
||||||
|
let body = '', error, match;
|
||||||
|
try { body = await resp.json(); } catch (e) {}
|
||||||
|
if (typeof body.error === 'string' && (match = /KeyError '(.*)'/.exec(body.error))) {
|
||||||
|
error = 'No column "' + match[1] + '" in table "' + tableId + '". ' +
|
||||||
|
'Be sure to use column ID rather than column label';
|
||||||
|
} else {
|
||||||
|
error = body.error || String(body);
|
||||||
|
}
|
||||||
|
throw new Error('Failed to add record: ' + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Convert FormData into a mapping of Grist fields. Skips any keys starting with underscore.
|
||||||
|
// For fields with multiple values (such as to populate ChoiceList), use field names like `foo[]`
|
||||||
|
// (with the name ending in a pair of empty square brackets).
|
||||||
|
function formDataToJson(f) {
|
||||||
|
const keys = Array.from(f.keys()).filter(k => !k.startsWith("_"));
|
||||||
|
return Object.fromEntries(keys.map(k =>
|
||||||
|
k.endsWith('[]') ? [k.slice(0, -2), ['L', ...f.getAll(k)]] : [k, f.get(k)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Handle submissions for plain forms that include special data-grist-* attributes.
|
||||||
|
async function handleSubmitPlainForm(ev) {
|
||||||
|
if (!['data-grist-doc', 'data-grist-table']
|
||||||
|
.some(attr => ev.target.hasAttribute(attr))) {
|
||||||
|
// This form isn't configured for Grist at all; don't interfere with it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const docUrl = ev.target.getAttribute('data-grist-doc');
|
||||||
|
const tableId = ev.target.getAttribute('data-grist-table');
|
||||||
|
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
|
||||||
|
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
|
||||||
|
|
||||||
|
const successUrl = ev.target.getAttribute('data-grist-success-url');
|
||||||
|
|
||||||
|
await gristFormSubmit(docUrl, tableId, new FormData(ev.target));
|
||||||
|
|
||||||
|
// On success, redirect to the requested URL.
|
||||||
|
if (successUrl) {
|
||||||
|
window.location.href = successUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
reportSubmitError(ev, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportSubmitError(ev, err) {
|
||||||
|
console.warn("grist-form-submit error:", err.message);
|
||||||
|
// Find an element to use for the validation message to alert the user.
|
||||||
|
let scapegoat = null;
|
||||||
|
(
|
||||||
|
(scapegoat = ev.submitter)?.setCustomValidity ||
|
||||||
|
(scapegoat = ev.target.querySelector('input[type=submit]'))?.setCustomValidity ||
|
||||||
|
(scapegoat = ev.target.querySelector('button'))?.setCustomValidity ||
|
||||||
|
(scapegoat = [...ev.target.querySelectorAll('input')].pop())?.setCustomValidity
|
||||||
|
)
|
||||||
|
scapegoat?.setCustomValidity("Form misconfigured: " + err.message);
|
||||||
|
ev.target.reportValidity();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle submissions for Contact Form 7 forms.
|
||||||
|
async function handleSubmitWPCF7(ev) {
|
||||||
|
try {
|
||||||
|
const formId = ev.detail.contactFormId;
|
||||||
|
const docUrl = ev.target.querySelector('[data-grist-doc]')?.getAttribute('data-grist-doc');
|
||||||
|
const tableId = ev.target.querySelector('[data-grist-table]')?.getAttribute('data-grist-table');
|
||||||
|
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
|
||||||
|
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
|
||||||
|
|
||||||
|
await gristFormSubmit(docUrl, tableId, new FormData(ev.target));
|
||||||
|
console.log("grist-form-submit WPCF7 Form %s: Added record", formId);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("grist-form-submit WPCF7 Form %s misconfigured:", formId, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUpGravityForms(options) {
|
||||||
|
// Use capture to get the event before GravityForms processes it.
|
||||||
|
document.addEventListener('submit', ev => handleSubmitGravityForm(ev, options), true);
|
||||||
|
}
|
||||||
|
gristFormSubmit.setUpGravityForms = setUpGravityForms;
|
||||||
|
|
||||||
|
async function handleSubmitGravityForm(ev, options) {
|
||||||
|
try {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const docUrl = options.docUrl;
|
||||||
|
const tableId = options.tableId;
|
||||||
|
if (!docUrl) { throw new Error("setUpGravityForm: missing docUrl option"); }
|
||||||
|
if (!tableId) { throw new Error("setUpGravityForm: missing tableId option"); }
|
||||||
|
|
||||||
|
const f = new FormData(ev.target);
|
||||||
|
for (const key of Array.from(f.keys())) {
|
||||||
|
// Skip fields other than input fields.
|
||||||
|
if (!key.startsWith("input_")) {
|
||||||
|
f.delete(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Rename multiple fields to use "[]" convention rather than ".N" convention.
|
||||||
|
const multi = key.split(".");
|
||||||
|
if (multi.length > 1) {
|
||||||
|
f.append(multi[0] + "[]", f.get(key));
|
||||||
|
f.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn("Processed FormData", f);
|
||||||
|
await gristFormSubmit(docUrl, tableId, f);
|
||||||
|
|
||||||
|
// Follow through by doing the form submission normally.
|
||||||
|
ev.target.submit();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
reportSubmitError(ev, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.gristFormSubmit = gristFormSubmit;
|
||||||
|
document.addEventListener('submit', handleSubmitPlainForm);
|
||||||
|
document.addEventListener('wpcf7mailsent', handleSubmitWPCF7);
|
||||||
|
|
||||||
|
})();
|
||||||
|
}
|
3
static/forms/purify.min.js
vendored
Normal file
3
static/forms/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
847
test/nbrowser/FormView.ts
Normal file
847
test/nbrowser/FormView.ts
Normal file
@ -0,0 +1,847 @@
|
|||||||
|
import {UserAPI} from 'app/common/UserAPI';
|
||||||
|
import {addToRepl, assert, driver, Key, WebElement, WebElementPromise} from 'mocha-webdriver';
|
||||||
|
import * as gu from 'test/nbrowser/gristUtils';
|
||||||
|
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
||||||
|
|
||||||
|
describe('FormView', function() {
|
||||||
|
this.timeout('90s');
|
||||||
|
|
||||||
|
let api: UserAPI;
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
|
const cleanup = setupTestSuite();
|
||||||
|
|
||||||
|
gu.withEnvironmentSnapshot({
|
||||||
|
'GRIST_EXPERIMENTAL_PLUGINS': '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
addToRepl('question', question);
|
||||||
|
addToRepl('labels', readLabels);
|
||||||
|
addToRepl('questionType', questionType);
|
||||||
|
const clipboard = gu.getLockableClipboard();
|
||||||
|
|
||||||
|
afterEach(() => gu.checkForErrors());
|
||||||
|
|
||||||
|
before(async function() {
|
||||||
|
const session = await gu.session().login();
|
||||||
|
docId = await session.tempNewDoc(cleanup);
|
||||||
|
api = session.createHomeApi();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFormWith(type: string, more = false) {
|
||||||
|
await gu.addNewSection('Form', 'Table1');
|
||||||
|
|
||||||
|
assert.isUndefined(await api.getTable(docId, 'Table1').then(t => t.D));
|
||||||
|
|
||||||
|
// Add a text question
|
||||||
|
await drop().click();
|
||||||
|
if (more) {
|
||||||
|
await clickMenu('More');
|
||||||
|
}
|
||||||
|
await clickMenu(type);
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Make sure we see this new question (D).
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
|
// Now open the form in external window.
|
||||||
|
const formUrl = await driver.find(`.test-forms-link`).getAttribute('href');
|
||||||
|
return formUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeForm() {
|
||||||
|
// Remove this section.
|
||||||
|
await gu.openSectionMenu('viewLayout');
|
||||||
|
await driver.find('.test-section-delete').click();
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Remove record.
|
||||||
|
await gu.sendActions([
|
||||||
|
['RemoveRecord', 'Table1', 1],
|
||||||
|
['RemoveColumn', 'Table1', 'D']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForConfirm() {
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.isTrue(await driver.findWait('.grist-form-confirm', 1000).isDisplayed());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSingle(value: any) {
|
||||||
|
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), [value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expect(values: any[]) {
|
||||||
|
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), values);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('can submit a form with Text field', async function() {
|
||||||
|
const formUrl = await createFormWith('Text');
|
||||||
|
// We are in a new window.
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
await driver.findWait('input[name="D"]', 1000).click();
|
||||||
|
await gu.sendKeys('Hello World');
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
// Make sure we see the new record.
|
||||||
|
await expectSingle('Hello World');
|
||||||
|
await removeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can submit a form with Numeric field', async function() {
|
||||||
|
const formUrl = await createFormWith('Numeric');
|
||||||
|
// We are in a new window.
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
await driver.findWait('input[name="D"]', 1000).click();
|
||||||
|
await gu.sendKeys('1984');
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
// Make sure we see the new record.
|
||||||
|
await expectSingle(1984);
|
||||||
|
await removeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can submit a form with Date field', async function() {
|
||||||
|
const formUrl = await createFormWith('Date');
|
||||||
|
// We are in a new window.
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
await driver.findWait('input[name="D"]', 1000).click();
|
||||||
|
await driver.executeScript(
|
||||||
|
() => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01'
|
||||||
|
);
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
// Make sure we see the new record.
|
||||||
|
await expectSingle(/* 2000-01-01 */946684800);
|
||||||
|
await removeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can submit a form with Choice field', async function() {
|
||||||
|
const formUrl = await createFormWith('Choice');
|
||||||
|
// Add some options.
|
||||||
|
await gu.openColumnPanel();
|
||||||
|
|
||||||
|
await gu.choicesEditor.edit();
|
||||||
|
await gu.choicesEditor.add('Foo');
|
||||||
|
await gu.choicesEditor.add('Bar');
|
||||||
|
await gu.choicesEditor.add('Baz');
|
||||||
|
await gu.choicesEditor.save();
|
||||||
|
await gu.toggleSidePanel('right', 'close');
|
||||||
|
|
||||||
|
// We need to press preview, as form is not saved yet.
|
||||||
|
await gu.scrollActiveViewTop();
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.isTrue(await driver.find('.test-forms-preview').isDisplayed());
|
||||||
|
});
|
||||||
|
await driver.find('.test-forms-preview').click();
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// We are in a new window.
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
// Make sure options are there.
|
||||||
|
assert.deepEqual(await driver.findAll('select[name="D"] option', e => e.getText()), ['Foo', 'Bar', 'Baz']);
|
||||||
|
await driver.findWait('select[name="D"]', 1000).click();
|
||||||
|
await driver.find("option[value='Bar']").click();
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
await expectSingle('Bar');
|
||||||
|
await removeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can submit a form with Integer field', async function() {
|
||||||
|
const formUrl = await createFormWith('Integer', true);
|
||||||
|
// We are in a new window.
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
await driver.findWait('input[name="D"]', 1000).click();
|
||||||
|
await gu.sendKeys('1984');
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
// Make sure we see the new record.
|
||||||
|
await expectSingle(1984);
|
||||||
|
await removeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can submit a form with Toggle field', async function() {
|
||||||
|
const formUrl = await createFormWith('Toggle', true);
|
||||||
|
// We are in a new window.
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
await driver.findWait('input[name="D"]', 1000).click();
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
await expectSingle(true);
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
await expect([true, false]);
|
||||||
|
|
||||||
|
// Remove the additional record added just now.
|
||||||
|
await gu.sendActions([
|
||||||
|
['RemoveRecord', 'Table1', 2],
|
||||||
|
]);
|
||||||
|
await removeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can submit a form with ChoiceList field', async function() {
|
||||||
|
const formUrl = await createFormWith('Choice List', true);
|
||||||
|
// Add some options.
|
||||||
|
await gu.openColumnPanel();
|
||||||
|
|
||||||
|
await gu.choicesEditor.edit();
|
||||||
|
await gu.choicesEditor.add('Foo');
|
||||||
|
await gu.choicesEditor.add('Bar');
|
||||||
|
await gu.choicesEditor.add('Baz');
|
||||||
|
await gu.choicesEditor.save();
|
||||||
|
await gu.toggleSidePanel('right', 'close');
|
||||||
|
// We are in a new window.
|
||||||
|
await gu.onNewTab(async () => {
|
||||||
|
await driver.get(formUrl);
|
||||||
|
await driver.findWait('input[name="D[]"][value="Foo"]', 1000).click();
|
||||||
|
await driver.findWait('input[name="D[]"][value="Baz"]', 1000).click();
|
||||||
|
await driver.find('input[type="submit"]').click();
|
||||||
|
await waitForConfirm();
|
||||||
|
});
|
||||||
|
await expectSingle(['L', 'Foo', 'Baz']);
|
||||||
|
|
||||||
|
await removeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a form for a blank table', async function() {
|
||||||
|
|
||||||
|
// Add new page and select form.
|
||||||
|
await gu.addNewPage('Form', 'New Table', {
|
||||||
|
tableName: 'Form'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure we see a form editor.
|
||||||
|
assert.isTrue(await driver.find('.test-forms-editor').isDisplayed());
|
||||||
|
|
||||||
|
// With 3 questions A, B, C.
|
||||||
|
for (const label of ['A', 'B', 'C']) {
|
||||||
|
assert.isTrue(
|
||||||
|
await driver.findContent('.test-forms-question .test-forms-label', gu.exactMatch(label)).isDisplayed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a submit button.
|
||||||
|
assert.isTrue(await driver.findContent('.test-forms-submit', gu.exactMatch('Submit')).isDisplayed());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doesnt generates fields when they are added', async function() {
|
||||||
|
await gu.sendActions([
|
||||||
|
['AddVisibleColumn', 'Form', 'Choice',
|
||||||
|
{type: 'Choice', widgetOption: JSON.stringify({choices: ['A', 'B', 'C']})}],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Make sure we see a form editor.
|
||||||
|
assert.isTrue(await driver.find('.test-forms-editor').isDisplayed());
|
||||||
|
await driver.sleep(100);
|
||||||
|
assert.isFalse(
|
||||||
|
await driver.findContent('.test-forms-question-choice .test-forms-label', gu.exactMatch('Choice')).isPresent()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports basic drag and drop', async function() {
|
||||||
|
|
||||||
|
// Make sure the order is right.
|
||||||
|
assert.deepEqual(
|
||||||
|
await readLabels(), ['A', 'B', 'C']
|
||||||
|
);
|
||||||
|
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('B')})
|
||||||
|
.press()
|
||||||
|
.move({origin: questionDrag('A')})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Make sure the order is right.
|
||||||
|
assert.deepEqual(
|
||||||
|
await readLabels(), ['B', 'A', 'C']
|
||||||
|
);
|
||||||
|
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('C')})
|
||||||
|
.press()
|
||||||
|
.move({origin: questionDrag('B')})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Make sure the order is right.
|
||||||
|
assert.deepEqual(
|
||||||
|
await readLabels(), ['C', 'B', 'A']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now move A on A and make sure nothing changes.
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('A')})
|
||||||
|
.press()
|
||||||
|
.move({origin: questionDrag('A'), x: 50})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['C', 'B', 'A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can undo drag and drop', async function() {
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['B', 'A', 'C']);
|
||||||
|
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new question at the end', async function() {
|
||||||
|
// We should see single drop zone.
|
||||||
|
assert.equal((await drops()).length, 1);
|
||||||
|
|
||||||
|
// Move the A over there.
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('A')})
|
||||||
|
.press()
|
||||||
|
.move({origin: drop().drag()})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['B', 'C', 'A']);
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// Now add a new question.
|
||||||
|
await drop().click();
|
||||||
|
|
||||||
|
await clickMenu('Text');
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// We should have new column D or type text.
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
assert.equal(await questionType('D'), 'Text');
|
||||||
|
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds question in the middle', async function() {
|
||||||
|
await driver.withActions(a => a.contextClick(question('B')));
|
||||||
|
await clickMenu('Insert question above');
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
|
||||||
|
|
||||||
|
// Now below C.
|
||||||
|
await driver.withActions(a => a.contextClick(question('B')));
|
||||||
|
await clickMenu('Insert question below');
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'E', 'C']);
|
||||||
|
|
||||||
|
// Make sure they are draggable.
|
||||||
|
// Move D infront of C.
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('D')})
|
||||||
|
.press()
|
||||||
|
.move({origin: questionDrag('C')})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'E', 'D', 'C']);
|
||||||
|
|
||||||
|
// Remove 3 times.
|
||||||
|
await gu.undo(3);
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selection works', async function() {
|
||||||
|
|
||||||
|
// Click on A.
|
||||||
|
await question('A').click();
|
||||||
|
|
||||||
|
// Now A is selected.
|
||||||
|
assert.equal(await selectedLabel(), 'A');
|
||||||
|
|
||||||
|
// Click B.
|
||||||
|
await question('B').click();
|
||||||
|
|
||||||
|
// Now B is selected.
|
||||||
|
assert.equal(await selectedLabel(), 'B');
|
||||||
|
|
||||||
|
// Click on the dropzone.
|
||||||
|
await drop().click();
|
||||||
|
await gu.sendKeys(Key.ESCAPE);
|
||||||
|
|
||||||
|
// Now nothing is selected.
|
||||||
|
assert.isFalse(await isSelected());
|
||||||
|
|
||||||
|
// When we add new question, it is automatically selected.
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu('Text');
|
||||||
|
await gu.waitForServer();
|
||||||
|
// Now D is selected.
|
||||||
|
assert.equal(await selectedLabel(), 'D');
|
||||||
|
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
await question('A').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hiding and revealing works', async function() {
|
||||||
|
await gu.toggleSidePanel('left', 'close');
|
||||||
|
await gu.openWidgetPanel();
|
||||||
|
|
||||||
|
// We have only one hidden column.
|
||||||
|
assert.deepEqual(await hiddenColumns(), ['Choice']);
|
||||||
|
|
||||||
|
// Now move it to the form on B
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: hiddenColumn('Choice')})
|
||||||
|
.press()
|
||||||
|
.move({origin: questionDrag('B')})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// It should be after A.
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Undo to make sure it is bundled.
|
||||||
|
await gu.undo();
|
||||||
|
|
||||||
|
// It should be hidden again.
|
||||||
|
assert.deepEqual(await hiddenColumns(), ['Choice']);
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// And redo.
|
||||||
|
await gu.redo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
|
||||||
|
assert.deepEqual(await hiddenColumns(), []);
|
||||||
|
|
||||||
|
// Now hide it using menu.
|
||||||
|
await question('Choice').rightClick();
|
||||||
|
await clickMenu('Hide');
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// It should be hidden again.
|
||||||
|
assert.deepEqual(await hiddenColumns(), ['Choice']);
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// And undo.
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
|
||||||
|
assert.deepEqual(await hiddenColumns(), []);
|
||||||
|
|
||||||
|
// Now hide it using Delete key.
|
||||||
|
await question('Choice').click();
|
||||||
|
await gu.sendKeys(Key.DELETE);
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// It should be hidden again.
|
||||||
|
assert.deepEqual(await hiddenColumns(), ['Choice']);
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
await gu.toggleSidePanel('right', 'close');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('basic keyboard navigation works', async function() {
|
||||||
|
await question('A').click();
|
||||||
|
assert.equal(await selectedLabel(), 'A');
|
||||||
|
|
||||||
|
// Move down.
|
||||||
|
await gu.sendKeys(Key.ARROW_DOWN);
|
||||||
|
assert.equal(await selectedLabel(), 'B');
|
||||||
|
|
||||||
|
// Move up.
|
||||||
|
await gu.sendKeys(Key.ARROW_UP);
|
||||||
|
assert.equal(await selectedLabel(), 'A');
|
||||||
|
|
||||||
|
// Move down to C.
|
||||||
|
await gu.sendKeys(Key.ARROW_DOWN);
|
||||||
|
await gu.sendKeys(Key.ARROW_DOWN);
|
||||||
|
assert.equal(await selectedLabel(), 'C');
|
||||||
|
|
||||||
|
// Move down we should be at A (past the submit button).
|
||||||
|
await gu.sendKeys(Key.ARROW_DOWN);
|
||||||
|
await gu.sendKeys(Key.ARROW_DOWN);
|
||||||
|
assert.equal(await selectedLabel(), 'A');
|
||||||
|
|
||||||
|
// Do the same with Left and Right.
|
||||||
|
await gu.sendKeys(Key.ARROW_RIGHT);
|
||||||
|
assert.equal(await selectedLabel(), 'B');
|
||||||
|
await gu.sendKeys(Key.ARROW_LEFT);
|
||||||
|
assert.equal(await selectedLabel(), 'A');
|
||||||
|
await gu.sendKeys(Key.ARROW_RIGHT);
|
||||||
|
await gu.sendKeys(Key.ARROW_RIGHT);
|
||||||
|
assert.equal(await selectedLabel(), 'C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cutting works', async function() {
|
||||||
|
const revert = await gu.begin();
|
||||||
|
await question('A').click();
|
||||||
|
// Send copy command.
|
||||||
|
await clipboard.lockAndPerform(async (cb) => {
|
||||||
|
await cb.cut();
|
||||||
|
await gu.sendKeys(Key.ARROW_DOWN); // Focus on B.
|
||||||
|
await gu.sendKeys(Key.ARROW_DOWN); // Focus on C.
|
||||||
|
await cb.paste();
|
||||||
|
});
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['B', 'A', 'C']);
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// To the same for paragraph.
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu('Paragraph');
|
||||||
|
await gu.waitForServer();
|
||||||
|
await element('Paragraph').click();
|
||||||
|
await clipboard.lockAndPerform(async (cb) => {
|
||||||
|
await cb.cut();
|
||||||
|
// Go over A and paste there.
|
||||||
|
await gu.sendKeys(Key.ARROW_UP); // Focus on button
|
||||||
|
await gu.sendKeys(Key.ARROW_UP); // Focus on C.
|
||||||
|
await gu.sendKeys(Key.ARROW_UP); // Focus on B.
|
||||||
|
await gu.sendKeys(Key.ARROW_UP); // Focus on A.
|
||||||
|
await cb.paste();
|
||||||
|
});
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Paragraph should be the first one now.
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
let elements = await driver.findAll('.test-forms-element');
|
||||||
|
assert.isTrue(await elements[0].matches('.test-forms-Paragraph'));
|
||||||
|
|
||||||
|
// Put it back using undo.
|
||||||
|
await gu.undo();
|
||||||
|
elements = await driver.findAll('.test-forms-element');
|
||||||
|
assert.isTrue(await elements[0].matches('.test-forms-question'));
|
||||||
|
// 0 - A, 1 - B, 2 - C, 3 - submit button.
|
||||||
|
assert.isTrue(await elements[4].matches('.test-forms-Paragraph'));
|
||||||
|
|
||||||
|
await revert();
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkInitial = async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
const checkNewCol = async () => {
|
||||||
|
assert.equal(await selectedLabel(), 'D');
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
await gu.undo();
|
||||||
|
await checkInitial();
|
||||||
|
};
|
||||||
|
const checkFieldsAtFirstLevel = (menuText: string) => {
|
||||||
|
it(`can add ${menuText} elements from the menu`, async function() {
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu(menuText);
|
||||||
|
await gu.waitForServer();
|
||||||
|
await checkNewCol();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
checkFieldsAtFirstLevel('Text');
|
||||||
|
checkFieldsAtFirstLevel('Numeric');
|
||||||
|
checkFieldsAtFirstLevel('Date');
|
||||||
|
checkFieldsAtFirstLevel('Choice');
|
||||||
|
|
||||||
|
const checkFieldInMore = (menuText: string) => {
|
||||||
|
it(`can add ${menuText} elements from the menu`, async function() {
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu('More');
|
||||||
|
await clickMenu(menuText);
|
||||||
|
await gu.waitForServer();
|
||||||
|
await checkNewCol();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
checkFieldInMore('Integer');
|
||||||
|
checkFieldInMore('Toggle');
|
||||||
|
checkFieldInMore('DateTime');
|
||||||
|
checkFieldInMore('Choice List');
|
||||||
|
checkFieldInMore('Reference');
|
||||||
|
checkFieldInMore('Reference List');
|
||||||
|
checkFieldInMore('Attachment');
|
||||||
|
|
||||||
|
const testStruct = (type: string) => {
|
||||||
|
it(`can add structure ${type} element`, async function() {
|
||||||
|
assert.equal(await elementCount(type), 0);
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu(type);
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.equal(await elementCount(type), 1);
|
||||||
|
await gu.undo();
|
||||||
|
assert.equal(await elementCount(type), 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
testStruct('Section');
|
||||||
|
testStruct('Columns');
|
||||||
|
testStruct('Paragraph');
|
||||||
|
|
||||||
|
it('basic section', async function() {
|
||||||
|
const revert = await gu.begin();
|
||||||
|
|
||||||
|
// Add structure.
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu('Section');
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.equal(await elementCount('Section'), 1);
|
||||||
|
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// There is a drop in that section, click it to add a new question.
|
||||||
|
await element('Section').element('dropzone').click();
|
||||||
|
await clickMenu('Text');
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
|
// And the question is inside a section.
|
||||||
|
assert.equal(await element('Section').element('label').getText(), 'D');
|
||||||
|
|
||||||
|
// Make sure we can move that question around.
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('D')})
|
||||||
|
.press()
|
||||||
|
.move({origin: questionDrag('B')})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
|
||||||
|
|
||||||
|
// Make sure that it is not inside the section anymore.
|
||||||
|
assert.equal(await element('Section').element('label').isPresent(), false);
|
||||||
|
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
assert.equal(await element('Section').element('label').getText(), 'D');
|
||||||
|
|
||||||
|
await revert();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('basic columns work', async function() {
|
||||||
|
const revert = await gu.begin();
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu('Columns');
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// We have two placeholders for free.
|
||||||
|
assert.equal(await elementCount('Placeholder', element('Columns')), 2);
|
||||||
|
|
||||||
|
// We can add another placeholder
|
||||||
|
await element('add').click();
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Now we have 3 placeholders.
|
||||||
|
assert.equal(await elementCount('Placeholder', element('Columns')), 3);
|
||||||
|
|
||||||
|
// We can click the middle one, and add a question.
|
||||||
|
await element('Columns').find(`.test-forms-editor:nth-child(2) .test-forms-Placeholder`).click();
|
||||||
|
await clickMenu('Text');
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Now we have 2 placeholders
|
||||||
|
assert.equal(await elementCount('Placeholder', element('Columns')), 2);
|
||||||
|
// And 4 questions.
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
|
// The question D is in the columns.
|
||||||
|
assert.equal(await element('Columns').element('label').getText(), 'D');
|
||||||
|
|
||||||
|
// We can move it around.
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('D')})
|
||||||
|
.press()
|
||||||
|
.move({origin: questionDrag('B')})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
|
||||||
|
|
||||||
|
// And move it back.
|
||||||
|
await driver.withActions(a =>
|
||||||
|
a.move({origin: questionDrag('D')})
|
||||||
|
.press()
|
||||||
|
.move({origin: element('Columns').find(`.test-forms-editor:nth-child(2) .test-forms-drag`)})
|
||||||
|
.release()
|
||||||
|
);
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
|
let allColumns = await driver.findAll('.test-forms-column');
|
||||||
|
|
||||||
|
assert.lengthOf(allColumns, 3);
|
||||||
|
assert.isTrue(await allColumns[0].matches('.test-forms-Placeholder'));
|
||||||
|
assert.isTrue(await allColumns[1].matches('.test-forms-question'));
|
||||||
|
assert.equal(await allColumns[1].find('.test-forms-label').getText(), 'D');
|
||||||
|
assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder'));
|
||||||
|
|
||||||
|
// Check that we can remove the question.
|
||||||
|
await question('D').rightClick();
|
||||||
|
await clickMenu('Hide');
|
||||||
|
await gu.waitForServer();
|
||||||
|
|
||||||
|
// Now we have 3 placeholders.
|
||||||
|
assert.equal(await elementCount('Placeholder', element('Columns')), 3);
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
|
||||||
|
// Undo and check it goes back at the right place.
|
||||||
|
await gu.undo();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
|
allColumns = await driver.findAll('.test-forms-column');
|
||||||
|
assert.lengthOf(allColumns, 3);
|
||||||
|
assert.isTrue(await allColumns[0].matches('.test-forms-Placeholder'));
|
||||||
|
assert.isTrue(await allColumns[1].matches('.test-forms-question'));
|
||||||
|
assert.equal(await allColumns[1].find('.test-forms-label').getText(), 'D');
|
||||||
|
assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder'));
|
||||||
|
|
||||||
|
await revert();
|
||||||
|
assert.lengthOf(await driver.findAll('.test-forms-column'), 0);
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes type of a question', async function() {
|
||||||
|
// Add text question as D column.
|
||||||
|
await drop().click();
|
||||||
|
await clickMenu('Text');
|
||||||
|
await gu.waitForServer();
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
|
// Make sure it is a text question.
|
||||||
|
assert.equal(await questionType('D'), 'Text');
|
||||||
|
|
||||||
|
// Now change it to a choice, from the backend (as the UI is not clear here).
|
||||||
|
await gu.sendActions([
|
||||||
|
['ModifyColumn', 'Form', 'D', {type: 'Choice', widgetOptions: JSON.stringify({choices: ['A', 'B', 'C']})}],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Make sure it is a choice question.
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.equal(await questionType('D'), 'Choice');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now change it back to a text question.
|
||||||
|
await gu.undo();
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.equal(await questionType('D'), 'Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
await gu.redo();
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.equal(await questionType('D'), 'Choice');
|
||||||
|
});
|
||||||
|
|
||||||
|
await gu.undo(2);
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function element(type: string, parent?: WebElement) {
|
||||||
|
return extra((parent ?? driver).find(`.test-forms-${type}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function elementCount(type: string, parent?: WebElement) {
|
||||||
|
return await (parent ?? driver).findAll(`.test-forms-${type}`).then(els => els.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLabels() {
|
||||||
|
return await driver.findAll('.test-forms-question .test-forms-label', el => el.getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
function question(label: string) {
|
||||||
|
return extra(driver.findContent('.test-forms-question .test-forms-label', gu.exactMatch(label))
|
||||||
|
.findClosest('.test-forms-editor'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionDrag(label: string) {
|
||||||
|
return question(label).find('.test-forms-drag');
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionType(label: string) {
|
||||||
|
return question(label).find('.test-forms-type').value();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop() {
|
||||||
|
return element('dropzone');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drops() {
|
||||||
|
return driver.findAll('.test-forms-dropzone');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickMenu(label: string) {
|
||||||
|
// First try command as it will also contain the keyboard shortcut we need to discard.
|
||||||
|
if (await driver.findContent('.grist-floating-menu li .test-cmd-name', gu.exactMatch(label)).isPresent()) {
|
||||||
|
return driver.findContent('.grist-floating-menu li .test-cmd-name', gu.exactMatch(label)).click();
|
||||||
|
}
|
||||||
|
return driver.findContentWait('.grist-floating-menu li', gu.exactMatch(label), 100).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected() {
|
||||||
|
return driver.findAll('.test-forms-field-editor-selected').then(els => els.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected() {
|
||||||
|
return driver.find('.test-forms-field-editor-selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedLabel() {
|
||||||
|
return selected().find('.test-forms-label').getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenColumns() {
|
||||||
|
return driver.findAll('.test-vfc-hidden-field', e => e.getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenColumn(label: string) {
|
||||||
|
return driver.findContent('.test-vfc-hidden-field', gu.exactMatch(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtraElement = WebElementPromise & {
|
||||||
|
rightClick: () => Promise<void>,
|
||||||
|
element: (type: string) => ExtraElement,
|
||||||
|
/**
|
||||||
|
* A draggable element inside. This is 2x2px div to help with drag and drop.
|
||||||
|
*/
|
||||||
|
drag: () => WebElementPromise,
|
||||||
|
};
|
||||||
|
|
||||||
|
function extra(el: WebElementPromise): ExtraElement {
|
||||||
|
const webElement: any = el;
|
||||||
|
|
||||||
|
webElement.rightClick = async function() {
|
||||||
|
await driver.withActions(a => a.contextClick(webElement));
|
||||||
|
};
|
||||||
|
|
||||||
|
webElement.element = function(type: string) {
|
||||||
|
return element(type, webElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
webElement.drag = function() {
|
||||||
|
return webElement.find('.test-forms-drag');
|
||||||
|
};
|
||||||
|
|
||||||
|
return webElement;
|
||||||
|
}
|
@ -1154,7 +1154,7 @@ export async function addNewTable(name?: string) {
|
|||||||
|
|
||||||
// Add a new page using the 'Add New' menu and wait for the new page to be shown.
|
// Add a new page using the 'Add New' menu and wait for the new page to be shown.
|
||||||
export async function addNewPage(
|
export async function addNewPage(
|
||||||
typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom',
|
typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom'|'Form',
|
||||||
tableRe: RegExp|string,
|
tableRe: RegExp|string,
|
||||||
options?: PageWidgetPickerOptions) {
|
options?: PageWidgetPickerOptions) {
|
||||||
const url = await driver.getCurrentUrl();
|
const url = await driver.getCurrentUrl();
|
||||||
@ -2855,7 +2855,8 @@ export async function duplicateTab() {
|
|||||||
export async function scrollActiveView(x: number, y: number) {
|
export async function scrollActiveView(x: number, y: number) {
|
||||||
await driver.executeScript(function(x1: number, y1: number) {
|
await driver.executeScript(function(x1: number, y1: number) {
|
||||||
const view = document.querySelector(".active_section .grid_view_data") ||
|
const view = document.querySelector(".active_section .grid_view_data") ||
|
||||||
document.querySelector(".active_section .detailview_scroll_pane");
|
document.querySelector(".active_section .detailview_scroll_pane") ||
|
||||||
|
document.querySelector(".active_section .test-forms-editor");
|
||||||
view!.scrollBy(x1, y1);
|
view!.scrollBy(x1, y1);
|
||||||
}, x, y);
|
}, x, y);
|
||||||
await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).
|
await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).
|
||||||
@ -2864,7 +2865,8 @@ export async function scrollActiveView(x: number, y: number) {
|
|||||||
export async function scrollActiveViewTop() {
|
export async function scrollActiveViewTop() {
|
||||||
await driver.executeScript(function() {
|
await driver.executeScript(function() {
|
||||||
const view = document.querySelector(".active_section .grid_view_data") ||
|
const view = document.querySelector(".active_section .grid_view_data") ||
|
||||||
document.querySelector(".active_section .detailview_scroll_pane");
|
document.querySelector(".active_section .detailview_scroll_pane") ||
|
||||||
|
document.querySelector(".active_section .test-forms-editor");
|
||||||
view!.scrollTop = 0;
|
view!.scrollTop = 0;
|
||||||
});
|
});
|
||||||
await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).
|
await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).
|
||||||
@ -3552,6 +3554,51 @@ export async function sendCommand(name: CommandName, argument: any = null) {
|
|||||||
await waitForServer();
|
await waitForServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper controller for choices list editor.
|
||||||
|
*/
|
||||||
|
export const choicesEditor = {
|
||||||
|
async hasReset() {
|
||||||
|
return (await driver.find(".test-choice-list-entry-edit").getText()) === "Reset";
|
||||||
|
},
|
||||||
|
async reset() {
|
||||||
|
await driver.find(".test-choice-list-entry-edit").click();
|
||||||
|
},
|
||||||
|
async label() {
|
||||||
|
return await driver.find(".test-choice-list-entry-row").getText();
|
||||||
|
},
|
||||||
|
async add(label: string) {
|
||||||
|
await driver.find(".test-tokenfield-input").click();
|
||||||
|
await driver.find(".test-tokenfield-input").clear();
|
||||||
|
await sendKeys(label, Key.ENTER);
|
||||||
|
},
|
||||||
|
async rename(label: string, label2: string) {
|
||||||
|
const entry = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${label}']`, 100);
|
||||||
|
await entry.click();
|
||||||
|
await sendKeys(label2);
|
||||||
|
await sendKeys(Key.ENTER);
|
||||||
|
},
|
||||||
|
async color(token: string, color: string) {
|
||||||
|
const label = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${token}']`, 100);
|
||||||
|
await label.findClosest(".test-tokenfield-token").find(".test-color-button").click();
|
||||||
|
await setFillColor(color);
|
||||||
|
await sendKeys(Key.ENTER);
|
||||||
|
},
|
||||||
|
async read() {
|
||||||
|
return await driver.findAll(".test-choice-list-entry-label", e => e.getText());
|
||||||
|
},
|
||||||
|
async edit() {
|
||||||
|
await this.reset();
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
await driver.find(".test-choice-list-entry-save").click();
|
||||||
|
await waitForServer();
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
await driver.find(".test-choice-list-entry-cancel").click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
} // end of namespace gristUtils
|
} // end of namespace gristUtils
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import { WebDriver, WebElement } from 'mocha-webdriver';
|
import { WebDriver, WebElement } from 'mocha-webdriver';
|
||||||
|
|
||||||
type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom';
|
type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom'|'Form';
|
||||||
|
|
||||||
export class GristWebDriverUtils {
|
export class GristWebDriverUtils {
|
||||||
public constructor(public driver: WebDriver) {
|
public constructor(public driver: WebDriver) {
|
||||||
|
13
yarn.lock
13
yarn.lock
@ -636,7 +636,7 @@
|
|||||||
resolved "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz"
|
resolved "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz"
|
||||||
integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A==
|
integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A==
|
||||||
|
|
||||||
"@types/dompurify@3.0.5", "@types/dompurify@^3.0.3":
|
"@types/dompurify@3.0.5":
|
||||||
version "3.0.5"
|
version "3.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
|
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
|
||||||
integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
|
integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
|
||||||
@ -2939,7 +2939,7 @@ domain-browser@~1.1.0:
|
|||||||
resolved "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz"
|
resolved "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz"
|
||||||
integrity sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=
|
integrity sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=
|
||||||
|
|
||||||
dompurify@3.0.6, dompurify@^3.0.6:
|
dompurify@3.0.6:
|
||||||
version "3.0.6"
|
version "3.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.6.tgz#925ebd576d54a9531b5d76f0a5bef32548351dae"
|
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.6.tgz#925ebd576d54a9531b5d76f0a5bef32548351dae"
|
||||||
integrity sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==
|
integrity sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==
|
||||||
@ -4774,15 +4774,6 @@ isobject@^3.0.1:
|
|||||||
resolved "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz"
|
resolved "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz"
|
||||||
integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
|
integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
|
||||||
|
|
||||||
isomorphic-dompurify@1.11.0:
|
|
||||||
version "1.11.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-1.11.0.tgz#83d9060a14fb7e02624b25c118194baa435ce86e"
|
|
||||||
integrity sha512-1Z8C9oPnbNGajiL9zAdf265aDWr8/PY8wvHR435uLxH8mnfurM9YklzmOZm6gH5XQkmIxIfAONq35eASx2xmKQ==
|
|
||||||
dependencies:
|
|
||||||
"@types/dompurify" "^3.0.3"
|
|
||||||
dompurify "^3.0.6"
|
|
||||||
jsdom "^23.0.0"
|
|
||||||
|
|
||||||
isstream@0.1.x:
|
isstream@0.1.x:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz"
|
resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user