(core) Forms improvements

Summary:
Forms improvements and following new design
- New headers
- New UI
- New right panel options

Test Plan: Tests updated

Reviewers: georgegevoian, dsagal

Reviewed By: georgegevoian

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D4158
pull/829/head
Jarosław Sadziński 4 months ago
parent b82209b458
commit 0aad09a4ed

@ -1,10 +1,12 @@
import {buildEditor} from 'app/client/components/Forms/Editor';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {Box, BoxModel} from 'app/client/components/Forms/Model';
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';
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
const testId = makeTestId('test-forms-');
@ -12,7 +14,7 @@ export class ColumnsModel extends BoxModel {
private _columnCount = Computed.create(this, use => use(this.children).length);
public removeChild(box: BoxModel) {
if (box.type.get() === 'Placeholder') {
if (box.type === 'Placeholder') {
// Make sure we have at least one rendered.
if (this.children.get().length <= 1) {
return;
@ -24,33 +26,29 @@ export class ColumnsModel extends BoxModel {
}
// Dropping a box on a column will replace it.
public drop(dropped: Box): BoxModel {
public accept(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);
const droppedRef = this.root().get(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);
public render(...args: IDomArgs<HTMLElement>): HTMLElement {
// Now render the dom.
const renderedDom = style.cssColumns(
const content: HTMLElement = 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')
);
const toRender = child ?? BoxModel.new(Placeholder(), this);
return toRender.render(testId('column'));
}),
// Append + button at the end.
@ -60,17 +58,17 @@ export class ColumnsModel extends BoxModel {
dom.on('click', () => this.placeAfterListChild()(Placeholder())),
style.cssColumn.cls('-add-button')
),
...args,
);
return renderedDom;
return buildEditor({ box: this, content });
}
}
export class PlaceholderModel extends BoxModel {
public render(context: RenderContext): HTMLElement {
const [box, view, overlay] = [this, this.view, context.overlay];
public render(...args: IDomArgs<HTMLElement>): HTMLElement {
const [box, view] = [this, this.view];
const scope = new MultiHolder();
overlay.set(false);
const liveIndex = Computed.create(scope, (use) => {
if (!box.parent) { return -1; }
@ -91,15 +89,17 @@ export class PlaceholderModel extends BoxModel {
const dragHover = Observable.create(scope, false);
return cssPlaceholder(
style.cssDrag(),
testId('placeholder'),
style.cssDrop(),
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, {
buildMenu({
box: this,
insertBox,
customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')],
}),
@ -133,7 +133,7 @@ export class PlaceholderModel extends BoxModel {
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = box.root().find(droppedId);
const droppedRef = box.root().get(droppedId);
if (!droppedRef) { return; }
// Now we simply insert it after this box.
@ -144,11 +144,13 @@ export class PlaceholderModel extends BoxModel {
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)));
}),
// If we an occupant, render it.
dom.maybe(boxModelAt, (child) => child.render()),
// If not, render a placeholder.
dom.maybe(not(boxModelAt), () =>
dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1)))
),
...args,
);
function insertBox(childBox: Box) {

@ -0,0 +1,209 @@
import {allCommands} from 'app/client/components/commands';
import {BoxModel, parseBox} from 'app/client/components/Forms/Model';
import {buildMenu} from 'app/client/components/Forms/Menu';
import * as style from 'app/client/components/Forms/styles';
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
const testId = makeTestId('test-forms-');
const t = makeT('FormView.Editor');
interface Props {
box: BoxModel,
/** Should we show an overlay */
overlay?: Observable<boolean>,
/** Custom drag indicator slot */
drag?: HTMLElement,
/**
* Actual element to put into the editor. This is the main content of the editor.
*/
content: DomContents,
/**
* Click handler. If not provided, then clicking on the editor will select it.
*/
click?: (ev: MouseEvent, box: BoxModel) => void,
/**
* Custom remove icon. If null, then no drop icon is shown.
*/
removeIcon?: IconName|null,
/**
* Custom remove button rendered atop overlay.
*/
removeButton?: DomContents,
/**
* Tooltip for the remove button.
*/
removeTooltip?: string,
/**
* Position of the remove button. Defaults to inside.
*/
removePosition?: 'inside'|'right',
editMode?: Observable<boolean>,
}
export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
const owner: MultiHolder = new MultiHolder();
const {box, overlay} = props;
const view = box.view;
const dragHover = Observable.create(owner, false);
let element: HTMLElement;
// When element is selected, scroll it into view.
owner.autoDispose(view.selectedBox.addListener(selectedBox => {
if (selectedBox === box) {
element?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
}
}));
// Default remove icon, can be overriden by props.
const defaultRemoveButton = () => style.cssRemoveButton(
icon((props.removeIcon as any) ?? 'RemoveBig'),
dom.on('click', ev => {
stopEvent(ev);
box.view.selectedBox.set(box);
allCommands.deleteFields.run();
}),
props.removeButton === null ? null : hoverTooltip(props.removeTooltip ?? t('Delete')),
style.cssRemoveButton.cls('-right', props.removePosition === 'right'),
);
const onClick = (ev: MouseEvent) => {
// 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 !== element) { return; }
ev.stopPropagation();
ev.preventDefault();
props.click?.(ev, props.box);
// Mark this box as selected.
box.view.selectedBox.set(box);
};
const dragAbove = Observable.create(owner, false);
const dragBelow = Observable.create(owner, false);
const dragging = Observable.create(owner, false);
return element = style.cssFieldEditor(
testId('editor'),
style.cssFieldEditor.cls('-drag-above', use => use(dragAbove) && use(dragHover)),
style.cssFieldEditor.cls('-drag-below', use => use(dragBelow) && use(dragHover)),
props.drag ?? style.cssDragWrapper(style.cssDrag('DragDrop')),
style.cssFieldEditor.cls(`-${props.box.type}`),
// Turn on active like state when we clicked here.
style.cssFieldEditor.cls('-selected', box.selected),
style.cssFieldEditor.cls('-cut', box.cut),
testId('field-editor-selected', box.selected),
// Select on click.
dom.on('click', onClick),
// Attach context menu.
buildMenu({
box,
context: true,
}),
// 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";
dragging.set(true);
}),
dom.on('dragover', (ev) => {
// As usual, prevent propagation.
ev.stopPropagation();
ev.preventDefault();
ev.stopImmediatePropagation();
// Here we just change the style of the element.
ev.dataTransfer!.dropEffect = "move";
dragHover.set(true);
if (dragging.get() || props.box.type === 'Section') { return; }
const myHeight = element.offsetHeight;
const percentHeight = Math.round((ev.offsetY / myHeight) * 100);
// If we are in the top half, we want to animate ourselves and transform a little below.
if (percentHeight < 40) {
dragAbove.set(true);
dragBelow.set(false);
} else if (percentHeight > 60) {
dragAbove.set(false);
dragBelow.set(true);
} else {
dragAbove.set(false);
dragBelow.set(false);
}
}),
dom.on('dragleave', (ev) => {
ev.stopPropagation();
ev.preventDefault();
// Just remove the style and stop propagation.
dragHover.set(false);
dragAbove.set(false);
dragBelow.set(false);
}),
dom.on('dragend', () => {
dragHover.set(false);
dragAbove.set(false);
dragBelow.set(false);
dragging.set(false);
}),
dom.on('drop', async (ev) => {
stopEvent(ev);
dragHover.set(false);
dragging.set(false);
dragAbove.set(false);
const wasBelow = dragBelow.get();
dragBelow.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 droppedModel = box.root().get(droppedId);
// It might happen that parent is dropped into child, so we need to check for that.
if (droppedModel?.get(box.id)) { return; }
await box.save(async () => {
droppedModel?.removeSelf();
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
});
}),
style.cssFieldEditor.cls('-drag-hover', dragHover),
style.cssFieldEditorContent(
props.content,
style.cssDrop(),
),
testId(box.type),
testId('element'),
dom.maybe(overlay, () => style.cssSelectedOverlay()),
// Custom icons for removing.
props.removeIcon === null || props.removeButton ? null :
dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
props.removeButton ?? null,
...args,
);
}

@ -1,133 +1,297 @@
import {buildEditor} from 'app/client/components/Forms/Editor';
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 {Box, BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
import * as css from 'app/client/components/Forms/styles';
import {stopEvent} from 'app/client/lib/domUtils';
import {refRecord} from 'app/client/models/DocModel';
import {autoGrow} from 'app/client/ui/forms';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors} from 'app/client/ui2018/cssVars';
import {Constructor} from 'app/common/gutil';
import {BindableValue, Computed, Disposable, dom, DomContents,
IDomComponent, makeTestId, Observable, toKo} from 'grainjs';
import {
BindableValue,
Computed,
Disposable,
dom,
DomContents,
DomElementArg,
IDomArgs,
makeTestId,
MultiHolder,
observable,
Observable,
styled,
toKo
} from 'grainjs';
import * as ko from 'knockout';
const testId = makeTestId('test-forms-');
/**
* Base class for all field models.
* Container class for all fields.
*/
export class FieldModel extends BoxModel {
/**
* Edit mode, (only one element can be in edit mode in the form editor).
*/
public edit = Observable.create(this, false);
public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)()));
public field = this.view.gristDoc.docModel.viewFields.createFloatingRowModel(this.fieldRef);
public field = refRecord(this.view.gristDoc.docModel.viewFields, this.fieldRef);
public colId = Computed.create(this, (use) => use(use(this.field).colId));
public column = Computed.create(this, (use) => use(use(this.field).column));
public question = Computed.create(this, (use) => {
return use(this.field.question) || use(this.field.origLabel);
const field = use(this.field);
if (field.isDisposed() || use(field.id) === 0) { return ''; }
return use(field.question) || use(field.origLabel);
});
public description = Computed.create(this, (use) => {
return use(this.field.description);
const field = use(this.field);
return use(field.description);
});
/**
* Column type of the field.
*/
public colType = Computed.create(this, (use) => {
return use(use(this.field.column).pureType);
const field = use(this.field);
return use(use(field.column).pureType);
});
/**
* Field row id.
*/
public get leaf() {
return this.props['leaf'] as Observable<number>;
}
/**
* A renderer of question instance.
*/
public renderer = Computed.create(this, (use) => {
const ctor = fieldConstructor(use(this.colType));
const instance = new ctor(this.field);
const instance = new ctor(this);
use.owner.autoDispose(instance);
return instance;
});
constructor(box: Box, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
this.question.onWrite(value => {
this.field.peek().question.setAndSave(value).catch(reportError);
});
this.autoDispose(
this.selected.addListener((now, then) => {
if (!now && then) {
setImmediate(() => !this.edit.isDisposed() && this.edit.set(false));
}
})
);
}
public async onDrop() {
await super.onDrop();
public async afterDrop() {
// Base class does good job of handling drop.
await super.afterDrop();
if (this.isDisposed()) { return; }
// Except when a field is dragged from the creator panel, which stores colId instead of fieldRef (as there is no
// field yet). In this case, we need to create a field.
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 override render(...args: IDomArgs<HTMLElement>): HTMLElement {
// Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
const save = (value: string) => {
value = value?.trim();
// If question is empty or same as original, don't save.
if (!value || value === this.field.peek().question()) {
return;
}
this.field.peek().question.setAndSave(value).catch(reportError);
};
const overlay = Observable.create(null, true);
const content = dom.domComputed(this.renderer, (r) => r.buildDom({
edit: this.edit,
overlay,
onSave: save,
}, ...args));
return buildEditor({
box: this,
overlay,
removeIcon: 'CrossBig',
removeTooltip: 'Hide',
editMode: this.edit,
content,
},
dom.on('dblclick', () => this.selected.get() && this.edit.set(true)),
);
}
public async deleteSelf() {
const rowId = this.field.getRowId();
const rowId = this.field.peek().id.peek();
const view = this.view;
const root = this.root();
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);
}
await root.save(async () => {
// Make sure to save first layout without this field, otherwise the undo won't work properly.
await root.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) {
export abstract class Question extends Disposable {
constructor(public model: FieldModel) {
super();
}
public abstract buildDom(): DomContents;
public buildDom(props: {
edit: Observable<boolean>,
overlay: Observable<boolean>,
onSave: (value: string) => void,
}, ...args: IDomArgs<HTMLElement>) {
return css.cssPadding(
testId('question'),
testType(this.model.colType),
this.renderLabel(props, dom.style('margin-bottom', '5px')),
this.renderInput(),
...args
);
}
public abstract renderInput(): DomContents;
protected renderLabel(props: {
edit: Observable<boolean>,
onSave: (value: string) => void,
}, ...args: DomElementArg[]) {
const {edit, onSave} = props;
const scope = new MultiHolder();
// When in edit, we will update a copy of the question.
const draft = Observable.create(scope, this.model.question.get());
scope.autoDispose(
this.model.question.addListener(q => draft.set(q)),
);
const controller = Computed.create(scope, (use) => use(draft));
controller.onWrite(value => {
if (this.isDisposed() || draft.isDisposed()) { return; }
if (!edit.get()) { return; }
draft.set(value);
});
// Wire up save method.
const saveDraft = (ok: boolean) => {
if (this.isDisposed() || draft.isDisposed()) { return; }
if (!ok || !edit.get() || !controller.get()) {
controller.set(this.model.question.get());
return;
}
onSave(controller.get());
};
let element: HTMLTextAreaElement;
scope.autoDispose(
props.edit.addListener((now, then) => {
if (now && !then) {
// When we go into edit mode, we copy the question into draft.
draft.set(this.model.question.get());
// And focus on the element.
setTimeout(() => {
element?.focus();
element?.select();
}, 10);
}
})
);
return [
dom.autoDispose(scope),
element = css.cssEditableLabel(
controller,
{onInput: true},
// Attach common Enter,Escape, blur handlers.
css.saveControls(edit, saveDraft),
// Autoselect whole text when mounted.
// Auto grow for textarea.
autoGrow(controller),
// Enable normal menu.
dom.on('contextmenu', stopEvent),
dom.style('resize', 'none'),
testId('label'),
css.cssEditableLabel.cls('-edit', props.edit),
// When selected, we want to be able to edit the label by clicking it
// so we need to make it relative and z-indexed.
dom.style('position', u => u(this.model.selected) ? 'relative' : 'static'),
dom.style('z-index', '2'),
dom.on('click', (ev) => {
if (this.model.selected.get() && !props.edit.get()) {
props.edit.set(true);
ev.stopPropagation();
}
}),
...args,
),
];
}
}
class TextModel extends Question {
public buildDom() {
return style.cssInput(
dom.prop('name', this.field.colId),
public renderInput() {
return css.cssInput(
dom.prop('name', u => u(u(this.model.field).colId)),
{disabled: true},
{type: 'text', tabIndex: "-1"},
ignoreClick
);
}
}
class ChoiceModel extends Question {
public buildDom() {
const field = this.field;
public renderInput() {
const field = this.model.field;
const choices: Computed<string[]> = Computed.create(this, use => {
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || [];
});
const typedChoices = Computed.create(this, use => {
const value = use(choices);
// Make sure it is array of strings.
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
return [];
}
return value;
});
return style.cssSelect(
return css.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.field.colId),
dom.forEach(choices, (choice) => dom('option', choice, {value: choice})),
dom.prop('name', use => use(use(field).colId)),
dom.forEach(typedChoices, (choice) => dom('option', choice, {value: choice})),
);
}
}
class ChoiceListModel extends Question {
public buildDom() {
const field = this.field;
public renderInput() {
const field = this.model.field;
const choices: Computed<string[]> = Computed.create(this, use => {
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
return use(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;'}
),
dom.prop('name', use => use(use(field).colId)),
dom.forEach(choices, (choice) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
choice
)),
dom.maybe(use => use(choices).length === 0, () => [
@ -138,25 +302,38 @@ class ChoiceListModel extends Question {
}
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'
public override buildDom(props: {
edit: Observable<boolean>,
overlay: Observable<boolean>,
question: Observable<string>,
onSave: () => void,
}) {
return css.cssPadding(
testId('question'),
testType(this.model.colType),
cssToggle(
this.renderInput(),
this.renderLabel(props, css.cssEditableLabel.cls('-normal')),
),
);
}
public override renderInput() {
const value = Observable.create(this, true);
return dom('div.widget_switch',
dom.style('--grist-actual-cell-color', colors.lightGreen.toString()),
dom.cls('switch_on', value),
dom.cls('switch_transition', true),
dom('div.switch_slider'),
dom('div.switch_circle'),
);
}
}
class DateModel extends Question {
public buildDom() {
public renderInput() {
return dom('div',
dom('input',
dom.prop('name', this.field.colId),
css.cssInput(
dom.prop('name', this.model.colId),
{type: 'date', style: 'margin-right: 5px; width: 100%;'
}),
);
@ -164,10 +341,10 @@ class DateModel extends Question {
}
class DateTimeModel extends Question {
public buildDom() {
public renderInput() {
return dom('div',
dom('input',
dom.prop('name', this.field.colId),
css.cssInput(
dom.prop('name', this.model.colId),
{type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'}
),
dom.style('width', '100%'),
@ -175,13 +352,61 @@ class DateTimeModel extends Question {
}
}
class RefListModel extends Question {
protected choices = this._subscribeForChoices();
public renderInput() {
return dom('div',
dom.prop('name', this.model.colId),
dom.forEach(this.choices, (choice) => css.cssLabel(
dom('input',
dom.prop('name', this.model.colId),
{type: 'checkbox', value: String(choice[0]), style: 'margin-right: 5px;'}
),
String(choice[1] ?? '')
)),
dom.maybe(use => use(this.choices).length === 0, () => [
dom('div', 'No choices defined'),
]),
) as HTMLElement;
}
private _subscribeForChoices() {
const tableId = Computed.create(this, use => {
const refTable = use(use(this.model.column).refTable);
return refTable ? use(refTable.tableId) : '';
});
const colId = Computed.create(this, use => {
const dispColumnIdObs = use(use(this.model.column).visibleColModel);
return use(dispColumnIdObs.colId);
});
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
return Computed.create(this, use => {
const unsorted = use(observer);
unsorted.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
return unsorted.slice(0, 50); // TODO: pagination or a waning
});
}
}
class RefModel extends RefListModel {
public renderInput() {
return css.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.model.colId),
dom.forEach(this.choices, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
);
}
}
// 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;
@ -208,3 +433,10 @@ function fieldConstructor(type: string): Constructor<Question> {
function testType(value: BindableValue<string>) {
return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
}
const cssToggle = styled('div', `
display: flex;
align-items: center;
gap: 8px;
--grist-actual-cell-color: ${colors.lightGreen};
`);

@ -1,34 +1,32 @@
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 {NewBox} from 'app/client/components/Forms/Menu';
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 {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {Disposable} from 'app/client/lib/dispose';
import {AsyncComputed, makeTestId} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {ShareRec} from 'app/client/models/entities/ShareRec';
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 {showTransientTooltip} from 'app/client/ui/tooltips';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {not} from 'app/common/gutil';
import {INITIAL_FIELDS_COUNT} from "app/common/Forms";
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs';
import {Computed, dom, Holder, IDomArgs, Observable} from 'grainjs';
import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid';
import * as ko from 'knockout';
const t = makeT('FormView');
@ -38,8 +36,8 @@ export class FormView extends Disposable {
public viewPane: HTMLElement;
public gristDoc: GristDoc;
public viewSection: ViewSectionRec;
public isEdit: Observable<boolean>;
public selectedBox: Observable<BoxModel | null>;
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
@ -60,12 +58,10 @@ export class FormView extends Disposable {
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.selectedBox = Observable.create(this, null);
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; }
@ -76,34 +72,49 @@ export class FormView extends Disposable {
this.cursor.setCursorPos({fieldIndex});
});
this.selectedColumns = this.autoDispose(ko.pureComputed(() => {
const result = this.viewSection.viewFields().all().filter((field, index) => {
// During column removal or restoring (with undo), some columns fields
// might be disposed.
if (field.isDisposed() || field.column().isDisposed()) { return false; }
return this.cursor.currentPosition().fieldIndex === index;
});
return result;
}));
// Wire up selected fields to the cursor.
this.autoDispose(this.selectedColumns.subscribe((columns) => {
this.viewSection.selectedFields(columns);
}));
this.viewSection.selectedFields(this.selectedColumns.peek());
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 this._formTemplate(fields);
}
return existing;
});
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async () => {
await this._saveNow();
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
await this.bundle(async () => {
// If the box is autogenerated we need to save it first.
if (!this.viewSection.layoutSpecObj.peek()?.id) {
await this.save();
}
if (clb) {
await clb();
}
await this.save();
});
}, this));
this._autoLayout.addListener((v) => {
if (this._saving) {
console.error('Layout changed while saving');
console.warn('Layout changed while saving');
return;
}
// When the layout has changed, we will update the root, but only when it is not the same
@ -140,20 +151,17 @@ export class FormView extends Disposable {
} 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();
// Remove the original box from the clipboard.
const cut = this._root.get(boxInClipboard.id);
cut?.removeSelf();
await this._root.save();
await navigator.clipboard.writeText('');
};
doPast().catch(reportError);
},
nextField: () => {
const current = this.selectedBox.get();
const all = [...this._root.list()];
const all = [...this._root.iterate()];
if (!all.length) { return; }
if (!current) {
this.selectedBox.set(all[0]);
@ -168,7 +176,7 @@ export class FormView extends Disposable {
},
prevField: () => {
const current = this.selectedBox.get();
const all = [...this._root.list()];
const all = [...this._root.iterate()];
if (!all.length) { return; }
if (!current) {
this.selectedBox.set(all[all.length - 1]);
@ -182,12 +190,12 @@ export class FormView extends Disposable {
}
},
lastField: () => {
const all = [...this._root.list()];
const all = [...this._root.iterate()];
if (!all.length) { return; }
this.selectedBox.set(all[all.length - 1]);
},
firstField: () => {
const all = [...this._root.list()];
const all = [...this._root.iterate()];
if (!all.length) { return; }
this.selectedBox.set(all[0]);
},
@ -204,39 +212,74 @@ export class FormView extends Disposable {
await selected.deleteSelf();
}).catch(reportError);
},
insertFieldBefore: (type: {field: BoxType} | {structure: BoxType}) => {
hideFields: (colId: [string]) => {
// Get the ref from colId.
const existing: Array<[number, string]> =
this.viewSection.viewFields().all().map(f => [f.id(), f.column().colId()]);
const ref = existing.filter(([_, c]) => colId.includes(c)).map(([r, _]) => r);
if (!ref.length) { return; }
const box = Array.from(this._root.filter(b => ref.includes(b.prop('leaf')?.get())));
box.forEach(b => b.removeSelf());
this._root.save(async () => {
await this.viewSection.removeField(ref);
}).catch(reportError);
},
insertFieldBefore: (what: NewBox) => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if ('field' in type) {
this.addNewQuestion(selected.placeBeforeMe(), type.field).catch(reportError);
if ('add' in what || 'show' in what) {
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
} else {
selected.insertBefore(components.defaultElement(type.structure));
selected.insertBefore(components.defaultElement(what.structure));
}
},
insertFieldAfter: (type: {field: BoxType} | {structure: BoxType}) => {
insertField: (what: NewBox) => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if ('field' in type) {
this.addNewQuestion(selected.placeAfterMe(), type.field).catch(reportError);
const place = selected.placeAfterListChild();
if ('add' in what || 'show' in what) {
this.addNewQuestion(place, what).catch(reportError);
} else {
selected.insertAfter(components.defaultElement(type.structure));
place(components.defaultElement(what.structure));
this.save().catch(reportError);
}
},
insertFieldAfter: (what: NewBox) => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if ('add' in what || 'show' in what) {
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
} else {
selected.insertAfter(components.defaultElement(what.structure));
}
},
showColumns: (colIds: string[]) => {
this.bundle(async () => {
// Sanity check that type is correct.
if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); }
this._root.save(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,
type: 'Field' as BoxType,
};
boxes.push(box);
}
boxes.forEach(b => this._root.append(b));
await this._saveNow();
// Add to selected or last section, or root.
const selected = this.selectedBox.get();
if (selected instanceof components.SectionModel) {
boxes.forEach(b => selected.append(b));
} else {
const topLevel = this._root.kids().reverse().find(b => b instanceof components.SectionModel);
if (topLevel) {
boxes.forEach(b => topLevel.append(b));
} else {
boxes.forEach(b => this._root.append(b));
}
}
}).catch(reportError);
},
};
@ -250,6 +293,7 @@ export class FormView extends Disposable {
shiftUp: keyboardActions.firstField,
editField: keyboardActions.edit,
deleteFields: keyboardActions.clearValues,
hideFields: keyboardActions.hideFields,
}, this, this.viewSection.hasFocus));
this._url = Computed.create(this, use => {
@ -273,8 +317,15 @@ export class FormView extends Disposable {
this._remoteShare = AsyncComputed.create(this, async (use) => {
const share = use(this._pageShare);
if (!share) { return null; }
const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId));
return remoteShare ?? null;
try {
const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId));
return remoteShare ?? null;
} catch(ex) {
// TODO: for now ignore the error, but the UI should be updated to not show editor
// for non owners.
if (ex.code === 'AUTH_NO_OWNER') { return null; }
throw ex;
}
});
this._published = Computed.create(this, use => {
@ -306,374 +357,60 @@ export class FormView extends Disposable {
}
public buildDom() {
return dom('div.flexauto.flexvbox',
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.cssFormEditBody(
return style.cssFormView(
testId('editor'),
style.cssFormEditBody(
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)) {
const element = child.render();
if (!(element instanceof Node)) {
throw new Error('Element is not an HTMLElement');
}
return element;
}),
this.buildDropzone(this, this._root.placeAfterListChild()),
this._buildPublisher(),
),
)),
dom.maybe(not(this.isEdit), () => [
style.cssPreview(
dom.prop('src', this._url),
)
]),
this._buildSwitcher(),
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,
dom.on('click', () => this.selectedBox.set(null))
);
}
public buildOverlay(owner: IDisposableOwner, box: BoxModel) {
public buildOverlay(...args: IDomArgs) {
return style.cssSelectedOverlay(
...args,
);
}
public async addNewQuestion(insert: Place, type: string) {
public async addNewQuestion(insert: Place, action: {add: string}|{show: 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,
// First save the layout, so that we don't have autogenerated layout.
await this.save();
// Now that the layout is saved, we won't be bothered with autogenerated layout,
// and we can safely insert to column.
const {fieldRef} = await this.insertColumn(null, {
colInfo: {
type,
}
});
let fieldRef = 0;
if ('show' in action) {
fieldRef = await this.showColumn(action.show);
} else {
const result = await this.insertColumn(null, {
colInfo: {
type: action.add,
}
});
fieldRef = result.fieldRef;
}
// 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() {
public async save() {
try {
this._saving = true;
const newVersion = {...this._root.toJSON()};
@ -690,13 +427,27 @@ export class FormView extends Disposable {
confirmModal(t('Publish your form?'),
t('Publish'),
async () => {
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
const page = this.viewSection.view().page();
if (!page) {
throw new Error('Unable to publish form: undefined page');
const page = this.viewSection.view().page();
if (!page) {
throw new Error('Unable to publish form: undefined page');
}
let validShare = page.shareRef() !== 0;
// If page is shared, make sure home server is aware of it.
if (validShare) {
try {
const pageShare = page.share();
const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());
validShare = !!serverShare;
} catch(ex) {
// TODO: for now ignore the error, but the UI should be updated to not show editor
if (ex.code === 'AUTH_NO_OWNER') {
return;
}
throw ex;
}
if (page.shareRef() === 0) {
}
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
if (!validShare) {
const shareRef = await this.gristDoc.docModel.docData.sendAction([
'AddRecord',
'_grist_Shares',
@ -715,6 +466,7 @@ export class FormView extends Disposable {
await share.optionsObj.save();
}
await this.save();
this.viewSection.shareOptionsObj.update({
form: true,
publish: true,
@ -780,31 +532,34 @@ export class FormView extends Disposable {
},
);
}
private _buildSwitcher() {
const toggle = (val: boolean) => () => {
this.isEdit.set(val);
this._saveNow().catch(reportError);
};
private _buildPublisher() {
return style.cssSwitcher(
this._buildSwitcherMessage(),
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.cls('-frameless'),
icon('Revert'),
testId('reset'),
dom('div', 'Reset form'),
dom.style('margin-right', 'auto'), // move it to the left
dom.on('click', () => {
this._resetForm().catch(reportError);
})
),
style.cssIconButton(
icon('EyeShow'),
dom('div', 'Preview'),
style.cssIconLink(
testId('preview'),
cssButton.cls('-primary', not(this.isEdit)),
style.cssIconButton.cls('-standard', (this.isEdit)),
dom.on('click', toggle(false))
icon('EyeShow'),
dom.text('Preview'),
dom.prop('href', this._url),
dom.prop('target', '_blank'),
dom.on('click', async (ev) => {
// If this form is not yet saved, we will save it first.
if (!this._savedLayout) {
stopEvent(ev);
await this.save();
window.open(this._url.get());
}
})
),
style.cssIconButton(
icon('FieldAttachment'),
@ -831,6 +586,10 @@ export class FormView extends Disposable {
});
await copyToClipboard(url);
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
} catch(ex) {
if (ex.code === 'AUTH_NO_OWNER') {
throw new Error('Publishing form is only available to owners');
}
} finally {
this._copyingLink.set(false);
}
@ -876,8 +635,81 @@ export class FormView extends Disposable {
);
});
}
/**
* Generates a form template based on the fields in the view section.
*/
private _formTemplate(fields: ViewFieldRec[]) {
const boxes: Box[] = fields.map(f => {
return {
type: 'Field',
leaf: f.id()
} as Box;
});
const section = {
type: 'Section',
children: [
{type: 'Paragraph', text: SECTION_TITLE},
{type: 'Paragraph', text: SECTION_DESC},
...boxes,
],
};
return {
type: 'Layout',
children: [
{type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
section,
{type: 'Submit'}
]
};
}
private async _resetForm() {
this.selectedBox.set(null);
await this.gristDoc.docData.bundleActions('Reset form', async () => {
// First we will remove all fields from this section, and add top 9 back.
const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());
const toAdd = this.viewSection.table().columns().peek().filter(c => {
// If hidden than no.
if (c.isHiddenCol()) { return false; }
// If formula column, no.
if (c.isFormula() && c.formula()) { return false; }
return true;
});
toAdd.sort((a, b) => a.parentPos() - b.parentPos());
const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());
const parentId = colRef.map(() => this.viewSection.id());
const parentPos = colRef.map((_, i) => i + 1);
const ids = colRef.map(() => null);
await this.gristDoc.docData.sendActions([
['BulkRemoveRecord', '_grist_Views_section_field', toDelete],
['BulkAddRecord', '_grist_Views_section_field', ids, {
colRef,
parentId,
parentPos,
}],
]);
const fields = this.viewSection.viewFields().all().slice(0, 9);
await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields));
});
}
}
// 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);
// Default values when form is reset.
const FORM_TITLE = "## **My Super Form**";
const FORM_DESC = "This is the UI design work in progress on Grist Forms. We are working hard to " +
"give you the best possible experience with this feature";
const SECTION_TITLE = '### **Header**';
const SECTION_DESC = 'Description';

@ -1,140 +0,0 @@
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;
`);

@ -0,0 +1,85 @@
import * as css from './styles';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {BoxModel} from 'app/client/components/Forms/Model';
import {stopEvent} from 'app/client/lib/domUtils';
import {not} from 'app/common/gutil';
import {Computed, dom, Observable} from 'grainjs';
export class LabelModel extends BoxModel {
public edit = Observable.create(this, false);
protected defaultValue = '';
public render(): HTMLElement {
let element: HTMLTextAreaElement;
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
const cssClass = this.prop('cssClass', '') as Observable<string>;
const editableText = Observable.create(this, text.get() || '');
const overlay = Computed.create(this, use => !use(this.edit));
this.autoDispose(text.addListener((v) => editableText.set(v || '')));
const save = (ok: boolean) => {
if (ok) {
text.set(editableText.get());
void this.parent?.save().catch(reportError);
} else {
editableText.set(text.get() || '');
}
};
const mode = (edit: boolean) => {
if (this.isDisposed() || this.edit.isDisposed()) { return; }
if (this.edit.get() === edit) { return; }
this.edit.set(edit);
};
return buildEditor(
{
box: this,
editMode: this.edit,
overlay,
click: (ev) => {
stopEvent(ev);
// If selected, then edit.
if (!this.selected.get()) { return; }
if (document.activeElement === element) { return; }
editableText.set(text.get() || '');
this.edit.set(true);
setTimeout(() => {
element.focus();
element.select();
}, 10);
},
content: element = css.cssEditableLabel(
editableText,
{onInput: true, autoGrow: true},
{placeholder: `Empty label`},
dom.on('click', ev => {
stopEvent(ev);
}),
// Styles saved (for titles and such)
css.cssEditableLabel.cls(use => `-${use(cssClass)}`),
// Disable editing if not in edit mode.
dom.boolAttr('readonly', not(this.edit)),
// Pass edit to css.
css.cssEditableLabel.cls('-edit', this.edit),
// Attach default save controls (Enter, Esc) and so on.
css.saveControls(this.edit, save),
// Turn off resizable for textarea.
dom.style('resize', 'none'),
),
},
dom.onKeyDown({Enter$: (ev) => {
// If no in edit mode, change it.
if (!this.edit.get()) {
mode(true);
ev.stopPropagation();
ev.stopImmediatePropagation();
ev.preventDefault();
return;
}
}})
);
}
}

@ -0,0 +1,170 @@
import {allCommands} from 'app/client/components/commands';
import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, BoxType, Place} from 'app/client/components/Forms/Model';
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
import * as menus from 'app/client/ui2018/menus';
import * as components from 'app/client/components/Forms/elements';
import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
const t = makeT('FormView');
const testId = makeTestId('test-forms-menu-');
// New box to add, either a new column of type, an existing column (by column id), or a structure.
export type NewBox = {add: string} | {show: string} | {structure: BoxType};
interface Props {
box?: BoxModel;
view?: FormView;
context?: boolean;
customItems?: Element[],
insertBox?: Place
}
export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArgs<HTMLElement> {
const {box, context, customItems} = props;
const view = box?.view ?? props.view;
if (!view) { throw new Error("No view provided"); }
const gristDoc = view.gristDoc;
const viewSection = view.viewSection;
const owner = new MultiHolder();
const unmapped = Computed.create(owner, (use) => {
const types = getNewColumnTypes(gristDoc, use(viewSection.tableId));
const normalCols = use(viewSection.hiddenColumns).filter(col => {
if (use(col.isHiddenCol)) { return false; }
if (use(col.isFormula) && use(col.formula)) { return false; }
if (use(col.pureType) === 'Attachments') { return false; }
return true;
});
const list = normalCols.map(col => {
return {
label: use(col.label),
icon: types.find(type => type.colType === use(col.pureType))?.icon ?? 'TypeCell',
colId: use(col.colId),
};
});
return list;
});
const oneTo5 = Computed.create(owner, (use) => use(unmapped).length > 0 && use(unmapped).length <= 5);
const moreThan5 = Computed.create(owner, (use) => use(unmapped).length > 5);
return [
dom.autoDispose(owner),
menus.menu((ctl) => {
box?.view.selectedBox.set(box);
// Same for structure.
const struct = (structure: BoxType) => ({structure});
// Actions:
// Insert field before and after.
const above = (el: NewBox) => () => {
allCommands.insertFieldBefore.run(el);
};
const below = (el: NewBox) => () => {
allCommands.insertFieldAfter.run(el);
};
const atEnd = (el: NewBox) => () => {
allCommands.insertField.run(el);
};
const custom = props.insertBox ? (el: NewBox) => () => {
if ('add' in el || 'show' in el) {
return view.addNewQuestion(props.insertBox!, el);
} else {
props.insertBox!(components.defaultElement(el.structure));
return view.save();
}
} : null;
// Field menus.
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
const disabled = ['Attachments'];
const commonTypes = () => getNewColumnTypes(gristDoc, viewSection.tableId());
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
const isEnabled = ({colType}: {colType: string}) => !disabled.includes(colType);
const insertMenu = (where: typeof above) => () => {
return [
menus.menuSubHeader('New question'),
...commonTypes()
.filter(isQuick)
.filter(isEnabled)
.map(ct => menus.menuItem(where({add: ct.colType}), menus.menuIcon(ct.icon!), ct.displayName))
,
menus.menuItemSubmenu(
() => commonTypes()
.filter(notQuick)
.filter(isEnabled)
.map(ct => menus.menuItem(
where({add: ct.colType}),
menus.menuIcon(ct.icon!),
ct.displayName,
)),
{},
menus.menuIcon('Dots'),
dom('span', "More", dom.style('margin-right', '8px'))
),
dom.maybe(oneTo5, () => [
menus.menuDivider(),
menus.menuSubHeader(t('Unmapped fields')),
dom.domComputed(unmapped, (uf) =>
uf.map(({label, icon, colId}) => menus.menuItem(
where({show: colId}),
menus.menuIcon(icon),
label,
testId('unmapped'),
testId('unmapped-' + colId)
)),
),
]),
dom.maybe(moreThan5, () => [
menus.menuDivider(),
menus.menuSubHeaderMenu(
() => unmapped.get().map(
({label, icon, colId}) => menus.menuItem(
where({show: colId}),
menus.menuIcon(icon),
label,
testId('unmapped'),
testId('unmapped-' + colId)
)),
{},
dom('span', "Unmapped fields", dom.style('margin-right', '8px'))
),
]),
menus.menuDivider(),
menus.menuSubHeader(t('Building blocks')),
menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")),
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Paragraph'), t("Paragraph")),
menus.menuItem(where(struct('Separator')), menus.menuIcon('Separator'), t("Separator")),
];
};
if (!props.context) {
return insertMenu(custom ?? atEnd)();
}
return [
menus.menuItemSubmenu(insertMenu(above), {action: above({add: 'Text'})}, t("Insert question above")),
menus.menuItemSubmenu(insertMenu(below), {action: below({add: 'Text'})}, t("Insert question below")),
menus.menuDivider(),
menus.menuItemCmd(allCommands.contextMenuCopy, t("Copy")),
menus.menuItemCmd(allCommands.contextMenuCut, t("Cut")),
menus.menuItemCmd(allCommands.contextMenuPaste, t("Paste")),
menus.menuDivider(),
menus.menuItemCmd(allCommands.deleteFields, "Hide"),
elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}),
customItems?.length ? menus.menuDivider(dom.style('min-width', '200px')) : null,
...(customItems ?? []),
...args,
];
}, {trigger: [context ? 'contextmenu' : 'click']}),
context ? dom.on('contextmenu', stopEvent) : null,
];
}

@ -1,13 +1,13 @@
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 {FormView} from 'app/client/components/Forms/FormView';
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field';
type Callback = () => Promise<void>;
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit'
| 'Placeholder' | 'Layout' | 'Field' | 'Label'
| 'Separator'
;
/**
* Box model is a JSON that represents a form element. Every element can be converted to this element and every
@ -35,6 +35,7 @@ export abstract class BoxModel extends Disposable {
const subClassName = `${box.type.split(':')[0]}Model`;
const factories = elements as any;
const factory = factories[subClassName];
if (!parent && !view) { throw new Error('Cannot create detached box'); }
// If we have a factory, use it.
if (factory) {
return new factory(box, parent, view || parent!.view);
@ -53,7 +54,7 @@ export abstract class BoxModel extends Disposable {
* 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>;
public type: BoxType;
/**
* List of children boxes.
*/
@ -68,18 +69,27 @@ export abstract class BoxModel extends Disposable {
*/
public cut = Observable.create(this, false);
public selected: Observable<boolean>;
/**
* Don't use it directly, use the BoxModel.new factory method instead.
*/
constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
super();
this.selected = Computed.create(this, (use) => use(view.selectedBox) === this && use(view.viewSection.hasFocus));
this.children = this.autoDispose(obsArray([]));
// We are owned by the parent children list.
if (parent) {
parent.children.autoDispose(this);
}
// 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([]));
this.type = box.type;
// And now update this and all children based on the box JSON.
bundleChanges(() => {
@ -96,7 +106,7 @@ export abstract class BoxModel extends Disposable {
* 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() {
public async afterDrop() {
}
@ -104,7 +114,7 @@ export abstract class BoxModel extends Disposable {
* 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 abstract render(...args: IDomArgs<HTMLElement>): HTMLElement;
public removeChild(box: BoxModel) {
@ -135,7 +145,7 @@ export abstract class BoxModel extends Disposable {
* Cuts self and puts it into clipboard.
*/
public async cutSelf() {
[...this.root().list()].forEach(box => box?.cut.set(false));
[...this.root().iterate()].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);
@ -145,7 +155,7 @@ export abstract class BoxModel extends Disposable {
* 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) {
public accept(dropped: Box, hint: 'above'|'below' = 'above') {
// Get the box that was dropped.
if (!dropped) { return null; }
if (dropped.id === this.id) {
@ -153,11 +163,11 @@ export abstract class BoxModel extends Disposable {
}
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = this.root().find(droppedId);
const droppedRef = this.root().get(droppedId);
if (droppedRef) {
droppedRef.removeSelf();
}
return this.placeBeforeMe()(dropped);
return hint === 'above' ? this.placeBeforeMe()(dropped) : this.placeAfterMe()(dropped);
}
public prop(name: string, defaultValue?: any) {
@ -167,9 +177,13 @@ export abstract class BoxModel extends Disposable {
return this.props[name];
}
public async save(): Promise<void> {
public hasProp(name: string) {
return this.props.hasOwnProperty(name);
}
public async save(before?: () => Promise<void>): Promise<void> {
if (!this.parent) { throw new Error('Cannot save detached box'); }
return this.parent.save();
return this.parent.save(before);
}
/**
@ -252,15 +266,29 @@ export abstract class BoxModel extends Disposable {
/**
* Finds a box with a given id in the tree.
*/
public find(droppedId: string): BoxModel | null {
public get(droppedId: string): BoxModel | null {
for (const child of this.kids()) {
if (child.id === droppedId) { return child; }
const found = child.find(droppedId);
const found = child.get(droppedId);
if (found) { return found; }
}
return null;
}
public* filter(filter: (box: BoxModel) => boolean): Iterable<BoxModel> {
for (const child of this.kids()) {
if (filter(child)) { yield child; }
yield* child.filter(filter);
}
}
public includes(box: BoxModel) {
for (const child of this.kids()) {
if (child === box) { return true; }
if (child.includes(box)) { return true; }
}
}
public kids() {
return this.children.get().filter(Boolean);
}
@ -271,15 +299,19 @@ export abstract class BoxModel extends Disposable {
*/
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));
if (this.type && boxDef.type !== this.type) {
if (!this.parent) { throw new Error('Cannot replace detached box'); }
this.parent.replace(this, BoxModel.new(boxDef, this.parent));
return;
}
// Update all properties of self.
for (const key in boxDef) {
// Skip some keys.
if (key === 'id' || key === 'type' || key === 'children') { continue; }
// Skip any inherited properties.
if (!boxDef.hasOwnProperty(key)) { continue; }
// Skip if the value is the same.
if (this.prop(key).get() === boxDef[key]) { continue; }
this.prop(key).set(boxDef[key]);
}
@ -296,11 +328,13 @@ export abstract class BoxModel extends Disposable {
}
}
if (!boxDef.children) { return; }
// 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];
const atIndexDef = boxDef.children[i];
atIndex.update(atIndexDef);
}
}
@ -311,16 +345,16 @@ export abstract class BoxModel extends Disposable {
public toJSON(): Box {
return {
id: this.id,
type: this.type.get() as BoxType,
type: this.type,
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> {
public * iterate(): IterableIterator<BoxModel> {
for (const child of this.kids()) {
yield child;
yield* child.list();
yield* child.iterate();
}
}
@ -330,35 +364,30 @@ export abstract class BoxModel extends Disposable {
}
export class LayoutModel extends BoxModel {
constructor(box: Box, public parent: BoxModel | null, public _save: () => Promise<void>, public view: FormView) {
constructor(
box: Box,
public parent: BoxModel | null,
public _save: (clb?: Callback) => Promise<void>,
public view: FormView
) {
super(box, parent, view);
}
public async save() {
return await this._save();
public async save(clb?: Callback) {
return await this._save(clb);
}
public render(): HTMLElement {
public override render(): HTMLElement {
throw new Error('Method not implemented.');
}
}
class DefaultBoxModel extends BoxModel {
public render(): HTMLElement {
return dom('div', `Unknown box type ${this.type.get()}`);
return dom('div', `Unknown box type ${this.type}`);
}
}
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();

@ -1,73 +1,64 @@
import * as css from './styles';
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {BoxModel} from 'app/client/components/Forms/Model';
import {textarea} from 'app/client/ui/inputs';
import {theme} from 'app/client/ui2018/cssVars';
import {not} from 'app/common/gutil';
import {Computed, dom, Observable, styled} from 'grainjs';
import {buildEditor} from 'app/client/components/Forms/Editor';
export class ParagraphModel extends BoxModel {
public edit = Observable.create(this, false);
public render(context: RenderContext) {
protected defaultValue = '**Lorem** _ipsum_ dolor';
protected cssClass = '';
private _overlay = Computed.create(this, not(this.selected));
public override render(): HTMLElement {
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);
});
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
box.edit.addListener((val) => {
if (!val) { return; }
setTimeout(() => element.focus(), 0);
});
// There is a spacial hack here. We might be created as a separator component, but the rendering
// for separator looks bad when it is the only content, so add a special case for that.
const isSeparator = Computed.create(this, (use) => use(text) === '---');
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);
}),
),
])
);
return buildEditor({
box: this,
overlay: this._overlay,
content: css.cssMarkdownRendered(
css.markdown(use => use(text) || '', dom.hide(editMode)),
dom.maybe(use => !use(text) && !use(editMode), () => cssEmpty('(empty)')),
css.cssMarkdownRendered.cls('-separator', isSeparator),
dom.on('click', () => {
if (!editMode.get() && this.selected.get()) {
editMode.set(true);
}
}),
css.cssMarkdownRendered.cls('-edit', editMode),
css.cssMarkdownRendered.cls(u => `-alignment-${u(box.prop('alignment', 'left'))}`),
this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null,
dom.maybe(editMode, () => {
const draft = Observable.create(null, text.get() || '');
setTimeout(() => element?.focus(), 10);
return [
element = cssTextArea(draft, {autoGrow: true, onInput: true},
cssTextArea.cls('-edit', editMode),
css.saveControls(editMode, (ok) => {
if (ok && editMode.get()) {
text.set(draft.get());
this.save().catch(reportError);
}
})
),
];
}),
)
});
}
}
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};
@ -79,6 +70,13 @@ const cssTextArea = styled(textarea, `
min-height: calc(3em * 1.5);
resize: none;
border-radius: 3px;
&-edit {
cursor: auto;
background: ${theme.inputBg};
outline: 2px solid black;
outline-offset: 1px;
border-radius: 2px;
}
&::placeholder {
color: ${theme.inputPlaceholderFg};
}

@ -1,25 +1,75 @@
import * as style from './styles';
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {dom} from 'grainjs';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {Box, BoxModel} from 'app/client/components/Forms/Model';
import {dom, styled} from 'grainjs';
import {makeTestId} from 'app/client/lib/domUtils';
const testId = makeTestId('test-forms-');
/**
* Component that renders a section of the form.
*/
export class SectionModel extends BoxModel {
public render(context: RenderContext) {
public override render(): HTMLElement {
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 buildEditor({
box: this,
// Custom drag element that is little bigger and at the top of the section.
drag: style.cssDragWrapper(style.cssDrag('DragDrop', style.cssDrag.cls('-top'))),
// No way to remove section now.
removeIcon: null,
// Content is just a list of children.
content: style.cssSection(
// Wrap them in a div that mutes hover events.
cssSectionItems(
dom.forEach(children, (child) => child.render()),
),
// Plus icon
style.cssPlusButton(
testId('plus'),
style.cssDrop(),
style.cssCircle(
style.cssPlusIcon('Plus'),
buildMenu({
box: this,
})
),
)
)},
style.cssSectionEditor.cls(''),
);
}
return element;
/**
* 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 override accept(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().get(droppedId);
if (droppedRef) {
droppedRef.removeSelf();
}
// Depending of the type of dropped box we need to insert it in different places.
// By default we insert it before this box.
let place = this.placeBeforeMe();
if (dropped.type === 'Field') {
// Fields are inserted after last child.
place = this.placeAfterListChild();
}
return place(dropped);
}
}
const cssSectionItems = styled('div.hover_border', `
`);

@ -1,10 +1,16 @@
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-');
import { BoxModel } from "app/client/components/Forms/Model";
import { makeTestId } from "app/client/lib/domUtils";
import { bigPrimaryButton } from "app/client/ui2018/buttons";
import { dom } from "grainjs";
const testId = makeTestId("test-forms-");
export class SubmitModel extends BoxModel {
public render(context: RenderContext) {
return primaryButton('Submit', testId('submit'));
public override render() {
const text = this.view.viewSection.layoutSpecObj.prop('submitText');
return dom(
"div",
{ style: "text-align: center; margin-top: 20px;" },
bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit"))
);
}
}

@ -1,26 +0,0 @@
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')),
]),
);
};

@ -0,0 +1,274 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, 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 UnmappedFieldsConfig extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
}
public buildDom() {
const unmappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
if (this._section.isDisposed()) {
return [];
}
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
const cols = this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
return cols.map(col => ({
col,
selected: Observable.create(null, false),
}));
})));
const mappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
if (this._section.isDisposed()) {
return [];
}
const cols = this._section.viewFields().map(f => f.column());
return cols.map(col => ({
col,
selected: Observable.create(null, false),
})).all();
})));
const anyUnmappedSelected = Computed.create(this, use => {
return use(unmappedColumns).some(c => use(c.selected));
});
const anyMappedSelected = Computed.create(this, use => {
return use(mappedColumns).some(c => use(c.selected));
});
const mapSelected = async () => {
await allCommands.showColumns.run(
unmappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));
};
const unMapSelected = async () => {
await allCommands.hideFields.run(
mappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));
};
return [
cssHeader(
cssFieldListHeader(t("Unmapped")),
selectAllLabel(
dom.on('click', () => {
unmappedColumns.get().forEach((col) => col.selected.set(true));
}),
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
),
),
dom('div',
testId('hidden-fields'),
dom.forEach(unmappedColumns, (field) => {
return this._buildUnmappedField(field);
})
),
dom.maybe(anyUnmappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Map fields")),
dom.on('click', mapSelected),
testId('visible-hide')
),
basicButton(
t("Clear"),
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear')
),
testId('visible-batch-buttons')
),
),
cssHeader(
cssFieldListHeader(dom.text(t("Mapped"))),
selectAllLabel(
dom.on('click', () => {
mappedColumns.get().forEach((col) => col.selected.set(true));
}),
dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0),
),
),
dom('div',
testId('visible-fields'),
dom.forEach(mappedColumns, (field) => {
return this._buildMappedField(field);
})
),
dom.maybe(anyMappedSelected, () =>
cssRow(
primaryButton(
dom.text(t("Unmap fields")),
dom.on('click', unMapSelected),
testId('visible-hide')
),
basicButton(
t("Clear"),
dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))),
testId('visible-clear')
),
testId('visible-batch-buttons')
),
),
];
}
private _buildUnmappedField(props: {col: ColumnRec, selected: Observable<boolean>}) {
const column = props.col;
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()]);
}),
),
squareCheckbox(props.selected),
),
);
}
private _buildMappedField(props: {col: ColumnRec, selected: Observable<boolean>}) {
const column = props.col;
return cssDragRow(
testId('visible-field'),
cssSimpleDragger(
cssSimpleDragger.cls('-hidden'),
),
cssFieldEntry(
cssFieldLabel(dom.text(column.label)),
cssHideIcon('EyeHide',
testId('hide'),
dom.on('click', () => {
allCommands.hideFields.run([column.colId.peek()]);
}),
),
squareCheckbox(props.selected),
),
);
}
}
function selectAllLabel(...args: any[]) {
return cssControlLabel(
testId('select-all'),
icon('Tick'),
dom('span', t("Select All")),
...args
);
}
const cssControlLabel = styled('div', `
--icon-color: ${theme.controlFg};
color: ${theme.controlFg};
cursor: pointer;
line-height: 16px;
`);
// 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;
}
&-hidden {
visibility: hidden !important;
}
`);
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: baseline;
justify-content: space-between;
margin-bottom: 12px;
line-height: 1em;
& * {
line-height: 1em;
}
`);

@ -10,11 +10,16 @@ export * from "./Section";
export * from './Field';
export * from './Columns';
export * from './Submit';
export * from './Label';
export function defaultElement(type: BoxType): Box {
switch(type) {
case 'Columns': return Columns();
case 'Placeholder': return Placeholder();
case 'Separator': return {
type: 'Paragraph',
text: '---',
};
default: return {type};
}
}

@ -1,72 +1,177 @@
import {textarea} from 'app/client/ui/inputs';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton} from 'app/client/ui2018/buttons';
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 {icon} from 'app/client/ui2018/icons';
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
import {marked} from 'marked';
export {
cssLabel,
cssDesc,
cssInput,
cssFieldEditor,
cssSelectedOverlay,
cssControls,
cssControlsLabel,
cssAddElement,
cssAddText,
cssFormContainer,
cssFormEdit,
cssFormEditBody,
cssSection,
cssStaticText,
};
const cssFormEditBody = styled('div', `
width: 100%;
overflow: auto;
padding-top: 52px;
`);
const cssFormEdit = styled('div', `
export const cssFormView = styled('div.flexauto.flexvbox', `
color: ${theme.text};
background-color: ${theme.leftPanelBg};
display: flex;
flex-direction: column;
flex-basis: 0px;
align-items: center;
justify-content: space-between;
position: relative;
background-color: ${theme.leftPanelBg};
overflow: auto;
min-height: 100%;
width: 100%;
`);
--section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */
&, &-preview {
background-color: ${theme.leftPanelBg};
overflow: auto;
min-height: 100%;
width: 100%;
position: relative;
flex-basis: 0px;
export const cssFormContainer = styled('div', `
background-color: ${theme.mainPanelBg};
border: 1px solid ${theme.modalBorderDark};
color: ${theme.text};
width: 600px;
align-self: center;
margin: 0px auto;
border-radius: 3px;
display: flex;
flex-direction: column;
max-width: calc(100% - 32px);
padding-top: 20px;
padding-left: 48px;
padding-right: 48px;
gap: 8px;
`);
export const cssFieldEditor = styled('div.hover_border.field_editor', `
position: relative;
cursor: pointer;
user-select: none;
outline: none;
padding: 8px;
border-radius: 3px;
margin-bottom: 4px;
--hover-visible: hidden;
transition: transform 0.2s ease-in-out;
&:hover:not(:has(.hover_border:hover),&-cut) {
--hover-visible: visible;
outline: 1px solid ${colors.lightGreen};
}
&-selected:not(&-cut) {
background: #F7F7F7;
outline: 1px solid ${colors.lightGreen};
--selected-block: block;
}
&: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;
}
&-FormDescription {
margin-bottom: 10px;
}
&-drag-above {
transform: translateY(2px);
}
&-drag-below {
transform: translateY(-2px);
}
`);
export const cssSectionEditor = styled('div', `
border-radius: 3px;
padding: 16px;
border: 1px solid ${theme.modalBorderDark};
`);
const cssLabel = styled('label', `
export const cssSection = styled('div', `
position: relative;
color: ${theme.text};
margin: 0px auto;
min-height: 50px;
.${cssFormView.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
min-height: auto;
}
`);
export const cssLabel = styled('label', `
font-size: 15px;
font-weight: normal;
margin-bottom: 8px;
user-select: none;
display: block;
margin: 0px;
`);
export const cssCheckboxLabel = styled('label', `
font-size: 15px;
font-weight: normal;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
margin: 0px;
margin-bottom: 8px;
`);
const cssDesc = styled('div', `
font-size: 10px;
export function textbox(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
return dom('input',
dom.prop('value', u => u(obs) || ''),
dom.on('input', (_e, elem) => obs.set(elem.value)),
...args,
);
}
export const cssEditableLabel = styled(textarea, `
font-weight: normal;
outline: none;
display: block;
padding: 0px;
border: 0px;
width: 100%;
margin: 0px;
background: transparent;
cursor: pointer;
min-height: 1.5rem;
color: ${colors.darkText};
font-size: 12px;
font-weight: 700;
&::placeholder {
font-style: italic
}
&-edit {
cursor: auto;
background: ${theme.inputBg};
outline: 2px solid black;
outline-offset: 1px;
border-radius: 2px;
}
&-normal {
color: ${colors.darkText};
font-size: 15px;
font-weight: normal;
}
`);
export const cssDesc = styled('div', `
font-size: 12px;
font-weight: 400;
margin-top: 4px;
color: ${colors.slate};
color: ${theme.darkText};
white-space: pre-wrap;
font-style: italic;
font-weight: 400;
line-height: 1.6;
`);
const cssInput = styled('input', `
flex: auto;
width: 100%;
export const cssInput = styled('input', `
font-size: inherit;
padding: 4px 8px;
border: 1px solid #D9D9D9;
@ -77,6 +182,9 @@ const cssInput = styled('input', `
&-invalid {
color: red;
}
&[type="number"], &[type="date"], &[type="datetime-local"], &[type="text"] {
width: 100%;
}
`);
export const cssSelect = styled('select', `
@ -94,33 +202,12 @@ export const cssSelect = styled('select', `
}
`);
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;
}
export const cssFieldEditorContent = styled('div', `
`);
const cssSelectedOverlay = styled('div', `
background: ${colors.selection};
export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
inset: 0;
position: absolute;
opacity: 0;
@ -129,59 +216,47 @@ const cssSelectedOverlay = styled('div', `
opacity: 1;
}
.${cssFormEdit.className}-preview & {
.${cssFormView.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', `
export const cssControlsLabel = styled('div', `
background: ${colors.lightGreen};
color: ${colors.light};
padding: 1px 2px;
min-width: 24px;
`);
const cssAddElement = styled('div', `
export const cssPlusButton = 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;
`);
export const cssCircle = styled('div', `
border-radius: 50%;
width: 24px;
height: 24px;
background-color: ${colors.lightGreen};
color: ${colors.light};
display: flex;
justify-content: center;
align-items: center;
.${cssPlusButton.className}:hover & {
background: ${colors.darkGreen};
}
`);
const cssAddText = styled('div', `
export const cssPlusIcon = styled(icon, `
--icon-color: ${colors.light};
`);
export const cssAddText = styled('div', `
color: ${colors.slate};
border-radius: 4px;
padding: 2px 4px;
@ -190,39 +265,22 @@ const cssAddText = styled('div', `
&:before {
content: "Add a field";
}
.${cssAddElement.className}-hover &:before {
.${cssPlusButton.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 cssPadding = styled('div', `
`);
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 & {
.${cssFormView.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
@ -269,11 +327,11 @@ export const cssColumn = styled('div', `
align-self: flex-end;
}
.${cssFormEdit.className}-preview &-add-button {
.${cssFormView.className}-preview &-add-button {
display: none;
}
.${cssFormEdit.className}-preview &-empty {
.${cssFormView.className}-preview &-empty {
background: transparent;
border-radius: unset;
padding: 0px;
@ -282,29 +340,43 @@ export const cssColumn = styled('div', `
}
`);
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;
flex-grow: 1;
gap: 16px;
max-width: calc(100% - 32px);
`);
export const cssButtonGroup = styled('div', `
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
padding: 0px 24px 0px 24px;
margin-bottom: 16px;
gap: 8px;
/* So that the height is 40px in normal state */
padding-top: calc((40px - 24px) / 2);
padding-bottom: calc((40px - 24px) / 2);
`);
export const cssIconLink = styled(basicButtonLink, `
padding: 3px 8px;
font-size: ${vars.smallFontSize};
display: flex;
align-items: center;
gap: 4px;
min-height: 24px;
&-standard {
background-color: ${theme.leftPanelBg};
}
&-warning {
color: ${theme.controlPrimaryFg};
background-color: ${theme.toastWarningBg};
border: none;
}
&-warning:hover {
color: ${theme.controlPrimaryFg};
background-color: #B8791B;
border: none;
}
&-frameless {
background-color: transparent;
border: none;
}
`);
export const cssIconButton = styled(basicButton, `
@ -328,21 +400,89 @@ export const cssIconButton = styled(basicButton, `
background-color: #B8791B;
border: none;
}
&-frameless {
background-color: transparent;
border: none;
}
`);
const cssStaticText = styled('div', `
export const cssMarkdownRendered = styled('div', `
min-height: 1.5rem;
font-size: 15px;
& textarea {
font-size: 15px;
}
& strong {
font-weight: 600;
}
&-alignment-left {
text-align: left;
}
&-alignment-center {
text-align: center;
}
&-alignment-right {
text-align: right;
}
& hr {
border-color: ${colors.darkGrey};
margin: 8px 0px;
}
&-separator {
display: flex;
flex-direction: column;
justify-content: center;
}
&-separator hr {
margin: 0px;
}
`);
export const cssMarkdownRender = styled('div', `
& > p:last-child {
margin-bottom: 0px;
}
& h1 {
font-size: 24px;
margin: 4px 0px;
font-weight: normal;
}
& h2 {
font-size: 22px;
margin: 4px 0px;
font-weight: normal;
}
& h3 {
font-size: 16px;
margin: 4px 0px;
font-weight: normal;
}
& h4 {
font-size: 13px;
margin: 4px 0px;
font-weight: normal;
}
& h5 {
font-size: 11px;
margin: 4px 0px;
font-weight: normal;
}
& h6 {
font-size: 10px;
margin: 4px 0px;
font-weight: normal;
}
`);
export function markdown(obs: BindableValue<string>, ...args: IDomArgs<HTMLDivElement>) {
return dom('div', el => {
return cssMarkdownRender(el => {
dom.autoDisposeElem(el, subscribeBindable(obs, val => {
el.innerHTML = sanitizeHTML(marked(val));
}));
}, ...args);
}
export const cssDrag = styled('div.test-forms-drag', `
export const cssDrop = styled('div.test-forms-drag', `
position: absolute;
pointer-events: none;
top: 2px;
@ -351,21 +491,45 @@ export const cssDrag = styled('div.test-forms-drag', `
height: 1px;
`);
export const cssDragWrapper = styled('div', `
position: absolute;
inset: 0px;
left: -16px;
top: 0px;
height: 100%;
width: 16px;
`);
export const cssDrag = styled(icon, `
position: absolute;
visibility: var(--hover-visible, hidden);
top: calc(50% - 16px / 2);
width: 16px;
height: 16px;
--icon-color: ${colors.lightGreen};
&-top {
top: 16px;
}
`);
export const cssPreview = styled('iframe', `
height: 100%;
width: 100%;
border: 0px;
`);
export const cssSwitcher = styled('div', `
flex-shrink: 0;
margin-top: 24px;
width: 100%;
border-top: 1px solid ${theme.modalBorder};
margin-left: -48px;
margin-right: -48px;
`);
export const cssSwitcherMessage = styled('div', `
display: flex;
padding: 0px 16px 0px 16px;
margin-bottom: 16px;
`);
export const cssSwitcherMessageBody = styled('div', `
@ -373,7 +537,7 @@ export const cssSwitcherMessageBody = styled('div', `
display: flex;
justify-content: center;
align-items: center;
padding: 0px 32px 0px 32px;
padding: 10px 32px;
`);
export const cssSwitcherMessageDismissButton = styled('div', `
@ -392,3 +556,73 @@ export const cssSwitcherMessageDismissButton = styled('div', `
export const cssParagraph = styled('div', `
margin-bottom: 16px;
`);
export const cssFormEditBody = styled('div', `
width: 100%;
overflow: auto;
padding-top: 52px;
padding-bottom: 24px;
`);
export const cssRemoveButton = styled('div', `
position: absolute;
right: 11px;
top: 11px;
border-radius: 3px;
background: ${colors.darkGrey};
display: none;
height: 16px;
width: 16px;
align-items: center;
justify-content: center;
line-height: 0px;
z-index: 3;
& > div {
height: 13px;
width: 13px;
}
&:hover {
background: ${colors.mediumGreyOpaque};
cursor: pointer;
}
.${cssFieldEditor.className}-selected > &,
.${cssFieldEditor.className}:hover > & {
display: flex;
}
&-right {
right: -20px;
}
`);
export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {
return [
dom.onKeyDown({
Enter$: (ev) => {
// if shift ignore
if (ev.shiftKey) {
return;
}
ev.stopPropagation();
ev.preventDefault();
save(true);
editMode.set(false);
if (ev.target && 'blur' in ev.target) {
(ev.target as any).blur();
}
},
Escape: (ev) => {
save(false);
editMode.set(false);
if (ev.target && 'blur' in ev.target) {
(ev.target as any).blur();
}
}
}),
dom.on('blur', (ev) => {
if (!editMode.isDisposed() && editMode.get()) {
save(true);
editMode.set(false);
}
}),
];
}

@ -38,6 +38,7 @@ import {DocPageModel} from 'app/client/models/DocPageModel';
import {UserError} from 'app/client/models/errors';
import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
import TableModel from 'app/client/models/TableModel';
import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory';
@ -45,7 +46,7 @@ import {startDocTour} from "app/client/ui/DocTour";
import {DocTutorial} from 'app/client/ui/DocTutorial';
import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
import {WebhookPage} from 'app/client/ui/WebhookPage';
import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
@ -71,7 +72,7 @@ import {StringUnion} from 'app/common/StringUnion';
import {TableData} from 'app/common/TableData';
import {getGristConfig} from 'app/common/urlUtils';
import {DocStateComparison} from 'app/common/UserAPI';
import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes';
import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType, WidgetType} from 'app/common/widgetTypes';
import {CursorPos, UIRowId} from 'app/plugin/GristAPI';
import {
bundleChanges,
@ -82,8 +83,10 @@ import {
fromKo,
Holder,
IDisposable,
IDisposableOwner,
IDomComponent,
keyframes,
MultiHolder,
Observable,
styled,
subscribe,
@ -474,6 +477,7 @@ export class GristDoc extends DisposableWithEvents {
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
setCursor: this.onSetCursorPos.bind(this),
createForm: this.onCreateForm.bind(this),
}, this, true));
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
@ -873,7 +877,7 @@ export class GristDoc extends DisposableWithEvents {
return;
}
}
const res = await docData.bundleActions(
const res: {sectionRef: number} = await docData.bundleActions(
t("Added new linked section to view {{viewName}}", {viewName}),
() => this.addWidgetToPageImpl(val, tableId ?? null)
);
@ -886,6 +890,21 @@ export class GristDoc extends DisposableWithEvents {
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
return res.sectionRef;
}
public async onCreateForm() {
const table = this.currentView.get()?.viewSection.tableRef.peek();
if (!table) {
return;
}
await this.addWidgetToPage({
...DefaultPageWidget(),
table,
type: WidgetType.Form,
});
commands.allCommands.expandSection.run();
}
/**
@ -914,7 +933,7 @@ export class GristDoc extends DisposableWithEvents {
return;
}
let newViewId: IDocPage;
if (val.type === 'record') {
if (val.type === WidgetType.Table) {
const result = await this.docData.sendAction(['AddEmptyTable', name]);
newViewId = result.views[0].id;
} else {
@ -1468,6 +1487,32 @@ export class GristDoc extends DisposableWithEvents {
this._showBackgroundVideoPlayer.set(false);
}
/**
* Creates computed with all the data for the given column.
*/
public columnObserver(owner: IDisposableOwner, tableId: Observable<string>, columnId: Observable<string>) {
const tableModel = Computed.create(owner, (use) => this.docModel.dataTables[use(tableId)]);
const refreshed = Observable.create(owner, 0);
const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);
const holder = Holder.create(owner);
const listener = (tab: TableModel) => {
// Now subscribe to any data change in that table.
const subs = MultiHolder.create(holder);
subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle));
subs.autoDispose(tab.tableData.tableActionEmitter.addListener(toggle));
tab.fetch().catch(reportError);
};
owner.autoDispose(tableModel.addListener(listener));
listener(tableModel.get());
const values = Computed.create(owner, refreshed, (use) => {
const rows = use(tableModel).getAllRows();
const colValues = use(tableModel).tableData.getColValues(use(columnId));
if (!colValues) { return []; }
return rows.map((row, i) => [row, colValues[i]]);
});
return values;
}
private _focusPreviousSection() {
const prevSectionId = this._prevSectionId;
if (!prevSectionId) { return; }

@ -197,7 +197,7 @@ export class LayoutTray extends DisposableWithEvents {
// And ask the viewLayout to save the specs.
viewLayout.saveLayoutSpec();
},
expandSection: () => {
restoreSection: () => {
// Get the section that is collapsed and clicked (we are setting this value).
const leafId = viewLayout.viewModel.activeCollapsedSectionId();
if (!leafId) { return; }

@ -147,21 +147,19 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() {
};
ViewConfigTab.prototype._buildThemeDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
var section = sectionData.section;
if (this.isDetail()) {
const theme = Computed.create(null, (use) => use(section.themeDef));
theme.onWrite(val => section.themeDef.setAndSave(val));
return cssRow(
dom.autoDispose(theme),
select(theme, [
{label: t("Form"), value: 'form' },
{label: t("Compact"), value: 'compact'},
{label: t("Blocks"), value: 'blocks' },
]),
testId('detail-theme')
);
}
return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => {
const section = sectionData.section;
const theme = Computed.create(null, (use) => use(section.themeDef));
theme.onWrite(val => section.themeDef.setAndSave(val));
return cssRow(
dom.autoDispose(theme),
select(theme, [
{label: t("Form"), value: 'form' },
{label: t("Compact"), value: 'compact'},
{label: t("Blocks"), value: 'blocks' },
]),
testId('detail-theme')
);
});
};
@ -170,21 +168,19 @@ ViewConfigTab.prototype._buildChartConfigDom = function() {
};
ViewConfigTab.prototype._buildLayoutDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
if (this.isDetail()) {
const view = sectionData.section.viewInstance.peek();
const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor());
return cssRow({style: 'margin-top: 16px;'},
kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()),
primaryButton(t("Edit Card Layout"),
dom.autoDispose(layoutEditorObs),
dom.on('click', () => commands.allCommands.editLayout.run()),
grainjsDom.hide(layoutEditorObs),
grainjsDom.cls('behavioral-prompt-edit-card-layout'),
testId('detail-edit-layout'),
)
);
}
return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => {
const view = sectionData.section.viewInstance.peek();
const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor());
return cssRow({style: 'margin-top: 16px;'},
kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()),
primaryButton(t("Edit Card Layout"),
dom.autoDispose(layoutEditorObs),
dom.on('click', () => commands.allCommands.editLayout.run()),
grainjsDom.hide(layoutEditorObs),
grainjsDom.cls('behavioral-prompt-edit-card-layout'),
testId('detail-edit-layout'),
)
);
});
};

@ -190,7 +190,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
prevSection: () => { this._otherSection(-1); },
printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
maximizeActiveSection: () => { this._maximizeActiveSection(); },
expandSection: () => { this._expandSection(); },
cancel: () => {
if (this.maximized.get()) {
this.maximized.set(null);
@ -294,7 +294,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
oldTray.dispose();
}
private _maximizeActiveSection() {
private _expandSection() {
const activeSection = this.viewModel.activeSection();
const activeSectionId = activeSection.getRowId();
const activeSectionBox = this.layout.getLeafBox(activeSectionId);

@ -22,7 +22,7 @@ export type CommandName =
| 'printSection'
| 'showRawData'
| 'openWidgetConfiguration'
| 'maximizeActiveSection'
| 'expandSection'
| 'leftPanelOpen'
| 'rightPanelOpen'
| 'videoTourToolsOpen'
@ -95,7 +95,7 @@ export type CommandName =
| 'addSection'
| 'deleteSection'
| 'collapseSection'
| 'expandSection'
| 'restoreSection'
| 'deleteCollapsedSection'
| 'duplicateRows'
| 'sortAsc'
@ -115,6 +115,8 @@ export type CommandName =
| 'activateAssistant'
| 'viewAsCard'
| 'showColumns'
| 'createForm'
| 'insertField'
;
@ -252,7 +254,7 @@ export const groups: CommendGroupDef[] = [{
desc: 'Open Custom widget configuration screen',
},
{
name: 'maximizeActiveSection',
name: 'expandSection',
keys: [],
desc: 'Maximize the active section',
},
@ -281,6 +283,16 @@ export const groups: CommendGroupDef[] = [{
keys: ['Space'],
desc: 'Show the record card widget of the selected record',
},
{
name: 'createForm',
keys: [],
desc: 'Creates form for active table',
},
{
name: 'insertField',
keys: [],
desc: 'Insert new column in default location',
},
]
}, {
group: 'Navigation',
@ -590,7 +602,7 @@ export const groups: CommendGroupDef[] = [{
keys: [],
desc: 'Collapse the currently active viewsection'
}, {
name: 'expandSection',
name: 'restoreSection',
keys: [],
desc: 'Expand collapsed viewsection'
}, {

@ -27,6 +27,7 @@ import {UserAction} from 'app/common/DocActions';
import {RecalcWhen} from 'app/common/gristTypes';
import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec';
import {WidgetType} from 'app/common/widgetTypes';
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
import {CursorPos, UIRowId} from 'app/plugin/GristAPI';
import {GristObjCode} from 'app/plugin/GristData';
@ -259,6 +260,8 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// Common type of selected columns or mixed.
columnsType: ko.PureComputed<string|'mixed'>;
widgetType: modelUtil.KoSaveableObservable<WidgetType>;
// Save all filters of fields/columns in the section.
saveFilters(): Promise<void>;
@ -276,9 +279,19 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewFieldInfo>;
/**
* Shows column (by adding a view field)
* @param col ColId or ColRef
* @param index Position to insert the column at
* @returns ViewField rowId
*/
showColumn(col: number|string, index?: number): Promise<number>
removeField(colRef: number): Promise<void>;
/**
* Removes one or multiple fields.
* @param colRef
*/
removeField(colRef: number|Array<number>): Promise<void>;
}
export type WidgetMappedColumn = number|number[]|null;
@ -361,6 +374,7 @@ export interface Filter {
}
export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void {
this.widgetType = this.parentKey as any;
this.viewFields = recordSet(this, docModel.viewFields, 'parentId', {sortBy: 'parentPos'});
this.linkedSections = recordSet(this, docModel.viewSections, 'linkSrcSectionRef');
@ -872,8 +886,13 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
return await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]);
};
this.removeField = async (fieldRef: number) => {
const action = ['RemoveRecord', fieldRef];
await docModel.viewFields.sendTableAction(action);
this.removeField = async (fieldRef: number|number[]) => {
if (Array.isArray(fieldRef)) {
const action = ['BulkRemoveRecord', fieldRef];
await docModel.viewFields.sendTableAction(action);
} else {
const action = ['RemoveRecord', fieldRef];
await docModel.viewFields.sendTableAction(action);
}
};
}

@ -52,18 +52,20 @@ export function buildDescriptionConfig(
export function buildTextInput(
owner: MultiHolder,
options: {
value: KoSaveableObservable<any>,
cursor: ko.Computed<CursorPos>,
label: string,
value: KoSaveableObservable<any>,
cursor?: ko.Computed<CursorPos>,
placeholder?: ko.Computed<string>,
},
...args: DomArg[]
) {
owner.autoDispose(
options.cursor.subscribe(() => {
options.value.save().catch(reportError);
})
);
if (options.cursor) {
owner.autoDispose(
options.cursor.subscribe(() => {
options.value.save().catch(reportError);
})
);
}
return [
cssLabel(options.label),
cssRow(
@ -84,7 +86,6 @@ const cssTextInput = styled(textInput, `
border: 1px solid ${theme.inputBorder};
width: 100%;
outline: none;
border-radius: 3px;
height: 28px;
border-radius: 3px;
padding: 0px 6px;

@ -59,6 +59,15 @@ export interface IPageWidget {
section: number;
}
export const DefaultPageWidget: () => IPageWidget = () => ({
type: 'record',
table: null,
summarize: false,
columns: [],
link: NoLink,
section: 0,
});
// Creates a IPageWidget from a ViewSectionRec.
export function toPageWidget(section: ViewSectionRec): IPageWidget {
const link = linkId({

@ -15,7 +15,9 @@
*/
import * as commands from 'app/client/components/commands';
import {HiddenQuestionConfig} from 'app/client/components/Forms/HiddenQuestionConfig';
import {FieldModel} from 'app/client/components/Forms/Field';
import {FormView} from 'app/client/components/Forms/FormView';
import {UnmappedFieldsConfig} from 'app/client/components/Forms/UnmappedFieldsConfig';
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import {EmptyFilterState} from "app/client/components/LinkingState";
import {RefSelect} from 'app/client/components/RefSelect';
@ -27,9 +29,11 @@ import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs
import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {buildDescriptionConfig, buildTextInput} from 'app/client/ui/DescriptionConfig';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {autoGrow} from 'app/client/ui/forms';
import {GridOptions} from 'app/client/ui/GridOptions';
import {textarea} from 'app/client/ui/inputs';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig";
import {cssLabel} from 'app/client/ui/RightPanelStyles';
@ -37,6 +41,8 @@ import {linkId, selectBy} from 'app/client/ui/selectBy';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {widgetTypesMap} from "app/client/ui/widgetTypesMap";
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {IconName} from 'app/client/ui2018/IconList';
@ -56,6 +62,7 @@ import {
DomContents,
DomElementArg,
DomElementMethod,
fromKo,
IDomComponent,
MultiHolder,
Observable,
@ -74,7 +81,7 @@ const t = makeT('RightPanel');
const TopTab = StringUnion("pageWidget", "field");
// Represents a subtab of pageWidget in the right side-pane.
const PageSubTab = StringUnion("widget", "sortAndFilter", "data");
const PageSubTab = StringUnion("widget", "sortAndFilter", "data", "submission");
// Returns the icon and label of a type, default to those associate to 'record' type.
export function getFieldType(widgetType: IWidgetType|null) {
@ -85,6 +92,7 @@ export function getFieldType(widgetType: IWidgetType|null) {
['single', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}],
['chart', {label: t('Series', { count: 1 }), icon: 'ChartLine', pluralLabel: t('Series', { count: 2 })}],
['custom', {label: t('Columns', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Columns', { count: 2 })}],
['form', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}],
]);
return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!;
@ -111,6 +119,10 @@ export class RightPanel extends Disposable {
return (use(section.parentKey) || null) as IWidgetType;
});
private _isForm = Computed.create(this, (use) => {
return use(this._pageWidgetType) === 'form';
});
// Returns the active section if it's valid, null otherwise.
private _validSection = Computed.create(this, (use) => {
const sec = use(this._gristDoc.viewModel.activeSection);
@ -135,6 +147,16 @@ export class RightPanel extends Disposable {
sortFilterTabOpen: () => this._openSortFilter(),
dataSelectionTabOpen: () => this._openDataSelection()
}, this, true));
// When a page widget is changed, subType might not be valid anymore, so reset it.
// TODO: refactor sub tabs and navigation using order of the tab.
this.autoDispose(subscribe((use) => {
if (!use(this._isForm) && use(this._subTab) === 'submission') {
setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('sortAndFilter'));
} else if (use(this._isForm) && use(this._subTab) === 'sortAndFilter') {
setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('submission'));
}
}));
}
private _openFieldTab() {
@ -216,13 +238,27 @@ export class RightPanel extends Disposable {
if (!use(this._isOpen)) { return null; }
const tool = use(this._extraTool);
if (tool) { return tabContentToDom(tool.content); }
const isForm = use(this._isForm);
const topTab = use(this._topTab);
if (topTab === 'field') {
return dom.create(this._buildFieldContent.bind(this));
}
if (topTab === 'pageWidget' && use(this._pageWidgetType)) {
return dom.create(this._buildPageWidgetContent.bind(this));
if (isForm) {
return dom.create(this._buildQuestionContent.bind(this));
} else {
return dom.create(this._buildFieldContent.bind(this));
}
} else if (topTab === 'pageWidget') {
if (isForm) {
return [
dom.create(this._buildPageFormHeader.bind(this)),
dom.create(this._buildPageWidgetContent.bind(this)),
];
} else {
return [
dom.create(this._buildPageWidgetHeader.bind(this)),
dom.create(this._buildPageWidgetContent.bind(this)),
];
}
}
return null;
});
@ -264,18 +300,6 @@ export class RightPanel extends Disposable {
// Builder for the reference display column multiselect.
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
const cursor = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
@ -289,14 +313,6 @@ export class RightPanel extends Disposable {
cssSection(
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
),
dom.maybe(isForm, () => [
cssSection(
dom.create(buildTextInput, {
cursor, label: 'Question', value: selectedField.question,
placeholder: selectedField.origLabel
}),
),
]),
cssSection(
dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }),
),
@ -357,7 +373,48 @@ export class RightPanel extends Disposable {
});
}
private _buildPageWidgetContent(_owner: MultiHolder) {
private _buildPageWidgetContent() {
const content = (activeSection: ViewSectionRec, type: typeof PageSubTab.type) => {
switch(type){
case 'widget': return dom.create(this._buildPageWidgetConfig.bind(this), activeSection);
case 'sortAndFilter': return [
dom.create(this._buildPageSortFilterConfig.bind(this)),
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
];
case 'data': return dom.create(this._buildPageDataConfig.bind(this), activeSection);
case 'submission': return dom.create(this._buildPageSubmissionConfig.bind(this), activeSection);
default: return null;
}
};
return dom.domComputed(this._subTab, (subTab) => (
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
content(activeSection, subTab)
)
))
));
}
private _buildPageFormHeader(_owner: MultiHolder) {
return [
cssSubTabContainer(
cssSubTab(t("Configuration"),
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'),
dom.on('click', () => this._subTab.set("widget")),
testId('config-widget')),
cssSubTab(t("Submission"),
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'submission'),
dom.on('click', () => this._subTab.set("submission")),
testId('config-submission')),
cssSubTab(t("Data"),
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'),
dom.on('click', () => this._subTab.set("data")),
testId('config-data')),
),
];
}
private _buildPageWidgetHeader(_owner: MultiHolder) {
return [
cssSubTabContainer(
cssSubTab(t("Widget"),
@ -373,19 +430,6 @@ export class RightPanel extends Disposable {
dom.on('click', () => this._subTab.set("data")),
testId('config-data')),
),
dom.domComputed(this._subTab, (subTab) => (
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
subTab === 'sortAndFilter' ? [
dom.create(this._buildPageSortFilterConfig.bind(this)),
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
] :
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
null
)
))
))
];
}
@ -449,21 +493,6 @@ export class RightPanel extends Disposable {
),
),
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)!), () => [
cssLabel(t("Theme")),
dom('div',
@ -526,9 +555,9 @@ export class RightPanel extends Disposable {
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
]),
dom.maybe(use => use(activeSection.parentKey) === 'form', () => [
dom.maybe(this._isForm, () => [
cssSeparator(),
dom.create(HiddenQuestionConfig, activeSection),
dom.create(UnmappedFieldsConfig, activeSection),
]),
]);
}
@ -733,10 +762,6 @@ export class RightPanel extends Disposable {
});
}
private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
const viewConfigTab = this._createViewConfigTab(owner);
const viewModel = this._gristDoc.viewModel;
@ -874,6 +899,180 @@ export class RightPanel extends Disposable {
));
}
}
private _buildPageSubmissionConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
// All of those observables are backed by the layout config.
const submitButtonKo = activeSection.layoutSpecObj.prop('submitText');
const toComputed = (obs: typeof submitButtonKo) => {
const result = Computed.create(owner, (use) => use(obs));
result.onWrite(val => obs.setAndSave(val));
return result;
};
const submitButton = toComputed(submitButtonKo);
const successText = toComputed(activeSection.layoutSpecObj.prop('successText'));
const successURL = toComputed(activeSection.layoutSpecObj.prop('successURL'));
const anotherResponse = toComputed(activeSection.layoutSpecObj.prop('anotherResponse'));
const redirection = Observable.create(owner, Boolean(successURL.get()));
owner.autoDispose(redirection.addListener(val => {
if (!val) {
successURL.set(null);
}
}));
owner.autoDispose(successURL.addListener(val => {
if (val) {
redirection.set(true);
}
}));
return [
cssLabel(t("Submit button label")),
cssRow(
cssTextInput(submitButton, (val) => submitButton.set(val)),
),
cssLabel(t("Success text")),
cssRow(
cssTextArea(successText, {onInput: true}, autoGrow(successText)),
),
cssLabel(t("Submit another response")),
cssRow(
labeledSquareCheckbox(anotherResponse, [
t("Display button"),
]),
),
cssLabel(t("Redirection")),
cssRow(
labeledSquareCheckbox(redirection, t('Redirect automatically after submission')),
),
cssRow(
cssTextInput(successURL, (val) => successURL.set(val)),
dom.show(redirection),
),
];
}
private _buildQuestionContent(owner: MultiHolder) {
const fieldBuilder = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
return vsi && vsi.activeFieldBuilder();
}));
const formView = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
return (vsi ?? null) as FormView|null;
}));
const selectedBox = Computed.create(owner, (use) => use(formView) && use(use(formView)!.selectedBox));
const selectedField = Computed.create(owner, (use) => {
const box = use(selectedBox);
if (!box) { return null; }
if (box.type !== 'Field') { return null; }
const fieldBox = box as FieldModel;
return use(fieldBox.field);
});
const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol));
const hasText = Computed.create(owner, (use) => {
const box = use(selectedBox);
if (!box) { return false; }
switch (box.type) {
case 'Submit':
case 'Paragraph':
case 'Label':
return true;
default:
return false;
}
});
return cssSection(
// Field config.
dom.maybe(selectedField, (field) => {
const requiredField = field.widgetOptionsJson.prop('formRequired');
// V2 thing.
// const hiddenField = field.widgetOptionsJson.prop('formHidden');
const defaultField = field.widgetOptionsJson.prop('formDefault');
const toComputed = (obs: typeof defaultField) => {
const result = Computed.create(null, (use) => use(obs));
result.onWrite(val => obs.setAndSave(val));
return result;
};
return [
cssLabel(t("Field title")),
cssRow(
cssTextInput(
fromKo(field.label),
(val) => field.displayLabel.saveOnly(val),
dom.prop('readonly', use => use(field.disableModify)),
),
),
cssLabel(t("Table column name")),
cssRow(
cssTextInput(
fromKo(field.colId),
(val) => field.column().colId.saveOnly(val),
dom.prop('readonly', use => use(field.disableModify)),
),
),
// TODO: this is for V1 as it requires full cell editor here.
// cssLabel(t("Default field value")),
// cssRow(
// cssTextInput(
// fromKo(defaultField),
// (val) => defaultField.setAndSave(val),
// ),
// ),
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
cssSeparator(),
cssLabel(t("COLUMN TYPE")),
cssSection(
builder.buildSelectTypeDom(),
),
// V2 thing
// cssSection(
// builder.buildSelectWidgetDom(),
// ),
dom.maybe(use => ['Choice', 'ChoiceList', 'Ref', 'RefList'].includes(use(builder.field.pureType)), () => [
cssSection(
builder.buildConfigDom(),
),
]),
]),
cssSeparator(),
cssLabel(t("Field rules")),
cssRow(labeledSquareCheckbox(toComputed(requiredField), t("Required field")),),
// V2 thing
// cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),),
];
}),
// Box config
dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [
cssLabel(dom.text(box.type)),
dom.maybe(hasText, () => [
cssRow(
cssTextArea(
box.prop('text'),
{onInput: true, autoGrow: true},
dom.on('blur', () => box.save().catch(reportError)),
{placeholder: t('Enter text')},
),
),
cssRow(
buttonSelect(box.prop('alignment'), [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
]),
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
)
]),
]),
// Default.
dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
cssLabel(t('Layout')),
])
);
}
}
function disabledSection() {
@ -1115,6 +1314,27 @@ const cssListItem = styled('li', `
padding: 4px 8px;
`);
const cssTextArea = styled(textarea, `
flex: 1 0 auto;
color: ${theme.inputFg};
background-color: ${theme.inputBg};
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
outline: none;
padding: 3px 7px;
/* Make space at least for two lines: size of line * 2 * line height + 2 * padding + border * 2 */
min-height: calc(2em * 1.5 + 2 * 3px + 2px);
line-height: 1.5;
resize: none;
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
pointer-events: none;
}
`);
const cssTextInput = styled(textInput, `
flex: 1 0 auto;
color: ${theme.inputFg};

@ -7,6 +7,7 @@ import {testId} from 'app/client/ui2018/cssVars';
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
import {GristDoc} from 'app/client/components/GristDoc';
import {dom, UseCB} from 'grainjs';
import {WidgetType} from 'app/common/widgetTypes';
const t = makeT('ViewLayoutMenu');
@ -63,8 +64,11 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
;
};
const isCard = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Card;
const isTable = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Table;
return [
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
dom.maybe(isCard, () => contextMenu),
dom.maybe(showRawData,
() => menuItemLink(
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
@ -91,6 +95,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)),
]),
menuDivider(dom.hide(viewSection.isRecordCard)),
@ -133,7 +138,7 @@ export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: G
)
),
menuDivider(),
menuItemCmd(allCommands.expandSection, t("Add to page"),
menuItemCmd(allCommands.restoreSection, t("Add to page"),
dom.cls('disabled', isReadonly),
testId('section-expand')),
menuItemCmd(allCommands.deleteCollapsedSection, t("Delete widget"),

@ -160,7 +160,7 @@ export function viewSectionMenu(
cssExpandIconWrapper(
cssSmallIcon('Grow'),
testId('expandSection'),
dom.on('click', () => allCommands.maximizeActiveSection.run()),
dom.on('click', () => allCommands.expandSection.run()),
hoverTooltip('Expand section', {key: 'expandSection'}),
),
)

@ -84,9 +84,20 @@ function resize(el: HTMLTextAreaElement) {
}
export function autoGrow(text: Observable<string>) {
// If this should autogrow we need to monitor width of this element.
return (el: HTMLTextAreaElement) => {
let width = 0;
const resizeObserver = new ResizeObserver((entries) => {
const elem = entries[0].target as HTMLTextAreaElement;
if (elem.offsetWidth !== width && width) {
resize(elem);
}
width = elem.offsetWidth;
});
resizeObserver.observe(el);
dom.onDisposeElem(el, () => resizeObserver.disconnect());
el.addEventListener('input', () => resize(el));
dom.autoDisposeElem(el, text.addListener(() => resize(el)));
dom.autoDisposeElem(el, text.addListener(() => setImmediate(() => resize(el))));
setTimeout(() => resize(el), 10);
dom.autoDisposeElem(el, text.addListener(val => {
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)

@ -1,3 +1,4 @@
import {autoGrow} from 'app/client/ui/forms';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {dom, DomElementArg, IDomArgs, IInputOptions, Observable, styled, subscribe} from 'grainjs';
@ -47,24 +48,50 @@ export function textInput(obs: Observable<string|undefined>, ...args: DomElement
);
}
export interface ITextAreaOptions extends IInputOptions {
autoGrow?: boolean;
save?: (value: string) => void;
}
export function textarea(
obs: Observable<string>, options: IInputOptions, ...args: IDomArgs<HTMLTextAreaElement>
obs: Observable<string>, options?: ITextAreaOptions|null, ...args: IDomArgs<HTMLTextAreaElement>
): HTMLTextAreaElement {
const isValid = options.isValid;
const isValid = options?.isValid;
function setValue(elem: HTMLTextAreaElement) {
obs.set(elem.value);
if (options?.save) { options.save(elem.value); }
else { obs.set(elem.value); }
if (isValid) { isValid.set(elem.validity.valid); }
}
const value = options?.autoGrow ? Observable.create(null, obs.get()) : null;
const trackInput = Boolean(options?.onInput || options?.autoGrow);
const onInput = trackInput ? dom.on('input', (e, elem: HTMLTextAreaElement) => {
if (options?.onInput) {
setValue(elem);
}
if (options?.autoGrow) {
value?.set(elem.value);
}
}) : null;
return dom('textarea', ...args,
dom.prop('value', obs),
value ? [
dom.autoDispose(value),
dom.autoDispose(obs.addListener(v => value.set(v))),
] : null,
dom.prop('value', use => use(obs) ?? ''),
(isValid ?
(elem) => dom.autoDisposeElem(elem,
subscribe(obs, (use) => isValid.set(elem.checkValidity()))) :
null),
options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null,
onInput,
options?.autoGrow ? [
autoGrow(value!),
dom.style('resize', 'none')
] : null,
dom.on('change', (e, elem) => setValue(elem)),
);
}

@ -49,11 +49,13 @@ export type IconName = "ChartArea" |
"Chat" |
"Code" |
"Collapse" |
"Columns" |
"Convert" |
"Copy" |
"CrossBig" |
"CrossSmall" |
"Database" |
"Desktop" |
"Dots" |
"Download" |
"DragDrop" |
@ -94,6 +96,7 @@ export type IconName = "ChartArea" |
"Message" |
"Minimize" |
"Minus" |
"Mobile" |
"MobileChat" |
"MobileChat2" |
"NewNotification" |
@ -102,6 +105,7 @@ export type IconName = "ChartArea" |
"Page" |
"PanelLeft" |
"PanelRight" |
"Paragraph" |
"Pencil" |
"PinBig" |
"PinSmall" |
@ -123,6 +127,8 @@ export type IconName = "ChartArea" |
"Robot" |
"Script" |
"Search" |
"Section" |
"Separator" |
"Settings" |
"Share" |
"Sort" |
@ -198,11 +204,13 @@ export const IconList: IconName[] = ["ChartArea",
"Chat",
"Code",
"Collapse",
"Columns",
"Convert",
"Copy",
"CrossBig",
"CrossSmall",
"Database",
"Desktop",
"Dots",
"Download",
"DragDrop",
@ -243,6 +251,7 @@ export const IconList: IconName[] = ["ChartArea",
"Message",
"Minimize",
"Minus",
"Mobile",
"MobileChat",
"MobileChat2",
"NewNotification",
@ -251,6 +260,7 @@ export const IconList: IconName[] = ["ChartArea",
"Page",
"PanelLeft",
"PanelRight",
"Paragraph",
"Pencil",
"PinBig",
"PinSmall",
@ -272,6 +282,8 @@ export const IconList: IconName[] = ["ChartArea",
"Robot",
"Script",
"Search",
"Section",
"Separator",
"Settings",
"Share",
"Sort",

@ -110,6 +110,7 @@ export const cssLabelText = styled('span', `
font-weight: initial; /* negate bootstrap */
overflow: hidden;
text-overflow: ellipsis;
line-height: 16px;
`);
type CheckboxArg = DomArg<HTMLInputElement>;

@ -132,7 +132,7 @@ export function menuItemSubmenu(
}
/**
* Subheader as a menu item.
* Header with a submenu (used in collapsed menus scenarios).
*/
export function menuSubHeaderMenu(
submenu: weasel.MenuCreateFunc,
@ -557,7 +557,7 @@ export const menuItem = styled(weasel.menuItem, menuItemStyle);
export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle);
// when element name is, to long, it will be trimmed with ellipsis ("...")
// when element name is too long, it will be trimmed with ellipsis ("...")
export function menuItemTrimmed(
action: (item: HTMLElement, ev: Event) => void, label: string, ...args: DomElementArg[]) {
return menuItem(action, cssEllipsisLabel(label), ...args);
@ -584,7 +584,7 @@ export function menuItemCmd(
typeof label === 'string'
? dom('span', label, testId('cmd-name'))
: dom('div', label(), testId('cmd-name')),
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
cmd.humanKeys?.length ? cssCmdKey(cmd.humanKeys[0]) : null,
cssMenuItemCmd.cls(''), // overrides some menu item styles
...args
);
@ -826,3 +826,52 @@ const cssMenuSearchInput = styled('input', `
color: ${theme.inputPlaceholderFg};
}
`);
type MenuDefinition = Array<MenuItem>;
interface MenuItem {
label?: string;
header?: string;
action?: string | (() => void);
disabled?: boolean;
icon?: IconName;
shortcut?: string;
submenu?: MenuDefinition;
maxSubmenu?: number;
type?: 'header' | 'separator' | 'item'; // default to item.
}
export function buildMenu(definition: MenuDefinition, onclick?: (action: string) => any) {
function *buildMenuItems(current: MenuDefinition): IterableIterator<Element> {
for (const item of current) {
const isHeader = item.type === 'header' || item.header;
// If this is header with submenu.
if (isHeader && item.submenu) {
yield menuSubHeaderMenu(() => [...buildMenuItems(item.submenu!)], {}, item.header ?? item.label);
continue;
} else if (isHeader) {
yield menuSubHeader(item.header ?? item.label);
continue;
}
// Not a header, so it's an item or a separator.
if (item.type === 'separator') {
yield menuDivider();
continue;
}
// If this is an item with submenu.
if (item.submenu) {
yield menuItemSubmenu(() => [...buildMenuItems(item.submenu!)], {}, item.label);
continue;
}
// Not a submenu, so it's a regular item.
const action = typeof item.action === 'function' ? item.action : () => onclick?.(item.action as string);
yield menuItem(action, item.icon && menuIcon(item.icon), item.label, item.shortcut && cssCmdKey(item.shortcut));
}
}
return menu((ctl) => [...buildMenuItems(definition)], {});
}

@ -9,7 +9,8 @@ import {icon} from 'app/client/ui2018/icons';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {choiceToken, DEFAULT_BACKGROUND_COLOR, DEFAULT_COLOR} from 'app/client/widgets/ChoiceToken';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {Computed, dom, styled} from 'grainjs';
import {WidgetType} from 'app/common/widgetTypes';
import {Computed, dom, styled, UseCB} from 'grainjs';
export type IChoiceOptions = Style
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
@ -84,8 +85,14 @@ export class ChoiceTextBox extends NTextBox {
use => !use(disabled)
&& (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions')))
);
// If we are on forms, we don't want to show alignment options.
const notForm = (use: UseCB) => {
return use(use(this.field.viewSection).parentKey) !== WidgetType.Form;
};
return [
super.buildConfigDom(),
dom.maybe(notForm, () => super.buildConfigDom()),
cssLabel(t('CHOICES')),
cssRow(
dom.autoDispose(disabled),

@ -35,6 +35,7 @@ import moment from 'moment';
import maxSize from 'popper-max-size-modifier';
import flatMap = require('lodash/flatMap');
import {autoGrow} from 'app/client/ui/forms';
import {autoFocus} from 'app/client/lib/domUtils';
const testId = makeTestId('test-discussion-');
const t = makeT('DiscussionEditor');
@ -919,9 +920,6 @@ function bindProp(text: Observable<string>) {
];
}
function autoFocus() {
return (el: HTMLElement) => void setTimeout(() => el.focus(), 10);
}
function buildPopup(
owner: Disposable,

@ -22,7 +22,6 @@ import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
import { textButton } from 'app/client/ui2018/buttons';
import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect';
import { theme } from 'app/client/ui2018/cssVars';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox';
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
@ -473,7 +472,7 @@ export class FieldBuilder extends Disposable {
// the dom created by the widgetImpl to get out of sync.
return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div', widget.buildConfigDom(), cssSeparator())
dom('div', widget.buildConfigDom())
)
);
}
@ -904,10 +903,6 @@ const cssTypeSelectMenu = styled('div', `
max-height: 500px;
`);
const cssSeparator = styled('div', `
border-bottom: 1px solid ${theme.pagePanelsBorder};
margin-top: 16px;
`);
// Simple helper that removes transparency from a HEX or rgba color.
// User can set a transparent fill color using doc actions, but we don't want to show it well

@ -9,8 +9,9 @@ import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {WidgetType} from 'app/common/widgetTypes';
import {UIRowId} from 'app/plugin/GristAPI';
import {Computed, dom, styled} from 'grainjs';
import {Computed, dom, styled, UseCB} from 'grainjs';
const t = makeT('Reference');
@ -48,10 +49,16 @@ export class Reference extends NTextBox {
}
public buildConfigDom() {
// If we are on forms, we don't want to show alignment options.
const notForm = (use: UseCB) => {
return use(use(this.field.viewSection).parentKey) !== WidgetType.Form;
};
return [
this.buildTransformConfigDom(),
cssLabel(t('CELL FORMAT')),
super.buildConfigDom()
dom.maybe(notForm, () => [
cssLabel(t('CELL FORMAT')),
super.buildConfigDom()
])
];
}

@ -3,6 +3,7 @@
margin: -1px auto;
width: 30px;
height: 17px;
flex: none;
}
.switch_slider {

@ -1,10 +1,24 @@
import {GristType} from 'app/plugin/GristData';
import {CellValue, GristType} from 'app/plugin/GristData';
import {MaybePromise} from 'app/plugin/gutil';
import _ from 'lodash';
import {marked} from 'marked';
/**
* This file is a part of the Forms project. It contains a logic to render an HTML form from a JSON definition.
* TODO: Client version has its own implementation, we should merge them but it is hard to tell currently
* what are the similarities and differences as a Client code should also support browsing.
*/
/**
* All allowed boxes.
*/
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field';
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' |
'Label';
/**
* Number of fields to show in the form by default.
*/
export const INITIAL_FIELDS_COUNT = 9;
/**
* Box model is a JSON that represents a form element. Every element can be converted to this element and every
@ -13,21 +27,37 @@ export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placehol
export interface Box extends Record<string, any> {
type: BoxType,
children?: Array<Box>,
// Some properties used by some boxes (like form itself)
submitText?: string,
successURL?: string,
successText?: string,
anotherResponse?: boolean,
}
/**
* When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML.
*/
export interface RenderContext {
root: Box;
field(id: number): FieldModel;
}
export interface FieldOptions {
formRequired?: boolean;
choices?: string[];
}
export interface FieldModel {
/**
* The question to ask. Fallbacks to column's label than column's id.
*/
question: string;
description: string;
colId: string;
type: string;
options: Record<string, any>;
options: FieldOptions;
values(): MaybePromise<[number, CellValue][]>;
}
/**
@ -36,9 +66,7 @@ export interface FieldModel {
*/
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`);
const ctr = elements[box.type] ?? Paragraph;
return new ctr(box, ctx);
}
@ -46,48 +74,69 @@ export class RenderBox {
}
public toHTML(): string {
return (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()).join('');
public async toHTML(): Promise<string> {
const proms = (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML());
const parts = await Promise.all(proms);
return parts.join('');
}
}
class Label extends RenderBox {
public override async toHTML() {
const text = this.box['text'];
const cssClass = this.box['cssClass'] || '';
return `
<div class="grist-label ${cssClass}">${text || ''}</div>
`;
}
}
class Paragraph extends RenderBox {
public override toHTML(): string {
public override async toHTML() {
const text = this.box['text'] || '**Lorem** _ipsum_ dolor';
const alignment = this.box['alignment'] || 'left';
const html = marked(text);
return `
<div class="grist-paragraph">${html}</div>
<div class="grist-paragraph grist-text-${alignment}">${html}</div>
`;
}
}
class Section extends RenderBox {
/** Nothing, default is enough */
public override async toHTML() {
return `
<div class="grist-section">
${await super.toHTML()}
</div>
`;
}
}
class Columns extends RenderBox {
public override toHTML(): string {
const kids = this.box.children || [];
public override async toHTML() {
const size = this.box.children?.length || 1;
const content = await super.toHTML();
return `
<div class="grist-columns" style='--grist-columns-count: ${kids.length}'>
${kids.map((child) => child.toHTML()).join('\n')}
<div class="grist-columns" style='--grist-columns-count: ${size}'>
${content}
</div>
`;
}
}
class Submit extends RenderBox {
public override toHTML() {
public override async toHTML() {
const text = _.escape(this.ctx.root['submitText'] || 'Submit');
return `
<div>
<input type='submit' value='Submit' />
<div class='grist-submit'>
<input type='submit' value='${text}' />
</div>
`;
}
}
class Placeholder extends RenderBox {
public override toHTML() {
public override async toHTML() {
return `
<div>
</div>
@ -105,93 +154,131 @@ class Layout extends RenderBox {
*/
class Field extends RenderBox {
public static render(field: FieldModel, context: RenderContext): string {
public build(field: FieldModel, context: RenderContext) {
const ctr = (questions as any)[field.type as any] as { new(): Question } || Text;
return new ctr().toHTML(field, context);
return new ctr();
}
public toHTML(): string {
public async toHTML() {
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>`;
const renderer = this.build(field, this.ctx);
return `
<div class="grist-field">
<label for='${name}'>${label}</label>
${html}
${description}
${await renderer.toHTML(field, this.ctx)}
</div>
`;
}
}
interface Question {
toHTML(field: FieldModel, context: RenderContext): string;
toHTML(field: FieldModel, context: RenderContext): Promise<string>|string;
}
abstract class BaseQuestion implements Question {
public async toHTML(field: FieldModel, context: RenderContext): Promise<string> {
return `
<div class='grist-question'>
${this.label(field)}
<div class='grist-field-content'>
${await this.input(field, context)}
</div>
</div>
`;
}
class Text implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
public label(field: FieldModel): string {
// This might be HTML.
const label = field.question;
const name = field.colId;
return `
<input type='text' name='${field.colId}' />
<label class='grist-label' for='${name}'>${label}</label>
`;
}
public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>;
}
class Date implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class Text extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='date' name='${field.colId}' />
<input type='text' name='${field.colId}' ${required}/>
`;
}
}
class DateTime implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class Date extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='datetime-local' name='${field.colId}' />
<input type='date' name='${field.colId}' ${required}/>
`;
}
}
class Choice implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class DateTime extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='datetime-local' name='${field.colId}' ${required}/>
`;
}
}
class Choice extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: string[] = field.options.choices || [];
return `
<select name='${field.colId}'>
<select name='${field.colId}' ${required} >
${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
</select>
`;
}
}
class Bool implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class Bool extends BaseQuestion {
public async toHTML(field: FieldModel, context: RenderContext) {
return `
<div class='grist-question'>
<div class='grist-field-content'>
${this.input(field, context)}
</div>
</div>
`;
}
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const label = field.question ? field.question : field.colId;
return `
<label>
<input type='checkbox' name='${field.colId}' value="1" />
Yes
<label class='grist-switch'>
<input type='checkbox' name='${field.colId}' value="1" ${required} />
<div class="grist-widget_switch grist-switch_transition">
<div class="grist-switch_slider"></div>
<div class="grist-switch_circle"></div>
</div>
<span>${label}</span>
</label>
`;
}
}
class ChoiceList implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class ChoiceList extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: string[] = field.options.choices || [];
return `
<div name='${field.colId}' class='grist-choice-list'>
<div name='${field.colId}' class='grist-choice-list ${required}'>
${choices.map((choice) => `
<label>
<input type='checkbox' name='${field.colId}[]' value='${choice}' />
${choice}
<span>
${choice}
</span>
</label>
`).join('')}
</div>
@ -199,6 +286,44 @@ class ChoiceList implements Question {
}
}
class RefList extends BaseQuestion {
public async input(field: FieldModel, context: RenderContext) {
const choices: [number, CellValue][] = (await field.values()) ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 20 choices, TODO: make it dynamic.
choices.splice(20);
return `
<div name='${field.colId}' class='grist-ref-list'>
${choices.map((choice) => `
<label class='grist-checkbox'>
<input type='checkbox' name='${field.colId}[]' value='${String(choice[0])}' />
<span>
${String(choice[1] ?? '')}
</span>
</label>
`).join('')}
</div>
`;
}
}
class Ref extends BaseQuestion {
public async input(field: FieldModel) {
const choices: [number, CellValue][] = (await field.values()) ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
// Support for 1000 choices, TODO: make it dynamic.
choices.splice(1000);
// <option type='number' is not standard, we parse it ourselves.
return `
<select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}'>
${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
</select>
`;
}
}
/**
* List of all available questions we will render of the form.
* TODO: add other renderers.
@ -210,6 +335,8 @@ const questions: Partial<Record<GristType, new () => Question>> = {
'ChoiceList': ChoiceList,
'Date': Date,
'DateTime': DateTime,
'Ref': Ref,
'RefList': RefList,
};
/**
@ -223,4 +350,5 @@ const elements = {
'Placeholder': Placeholder,
'Layout': Layout,
'Field': Field,
'Label': Label,
};

@ -982,7 +982,7 @@ export function useBindable<T>(use: UseCBOwner, obs: BindableValue<T>): T {
}
/**
* Use helper for simple boolean negation.
* Useful helper for simple boolean negation.
*/
export const not = (obs: Observable<any>|IKnockoutReadObservable<any>) => (use: UseCBOwner) => !use(obs);

@ -9,3 +9,12 @@ export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;
// all widget types
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | 'form' | IAttachedCustomWidget;
export enum WidgetType {
Table = 'record',
Card = 'single',
CardList = 'detail',
Chart = 'chart',
Custom = 'custom',
Form = 'form',
Calendar = 'custom.calendar',
}

@ -7,3 +7,5 @@ import times = require('lodash/times');
export function arrayRepeat<T>(count: number, value: T): T[] {
return times(count, constant(value));
}
export type MaybePromise<T> = T | Promise<T>;

@ -12,8 +12,8 @@ import {
UserAction
} from 'app/common/DocActions';
import {DocData} from 'app/common/DocData';
import {isRaisedException} from "app/common/gristTypes";
import {Box, RenderBox, RenderContext} from "app/common/Forms";
import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes";
import {Box, BoxType, FieldModel, INITIAL_FIELDS_COUNT, RenderBox, RenderContext} from "app/common/Forms";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
import {SchemaTypes} from "app/common/schema";
@ -86,11 +86,11 @@ import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload,
import * as assert from 'assert';
import contentDisposition from 'content-disposition';
import {Application, NextFunction, Request, RequestHandler, Response} from "express";
import jsesc from 'jsesc';
import * as fse from 'fs-extra';
import * as handlebars from 'handlebars';
import * as _ from "lodash";
import LRUCache from 'lru-cache';
import * as moment from 'moment';
import * as fse from 'fs-extra';
import fetch from 'node-fetch';
import * as path from 'path';
import * as t from "ts-interface-checker";
@ -159,6 +159,17 @@ function validateCore(checker: Checker, req: Request, body: any) {
}
}
/**
* Helper used in forms rendering for purifying html.
*/
handlebars.registerHelper('dompurify', (html: string) => {
return new handlebars.SafeString(`
<script data-html="${handlebars.escapeExpression(html)}">
document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html')));
</script>
`);
});
export class DocWorkerApi {
// Map from docId to number of requests currently being handled for that doc
private _currentUsage = new Map<string, number>();
@ -1398,90 +1409,129 @@ export class DocWorkerApi {
sectionId,
});
}
// Get the viewSection record for the specified id.
const records = asRecords(await readTable(
req, activeDoc, '_grist_Views_section', { id: [sectionId] }, {}
));
const section = records.find(r => r.id === sectionId);
const Views_section = activeDoc.docData!.getMetaTable('_grist_Views_section');
const section = Views_section.getRecord(sectionId);
if (!section) {
throw new ApiError('Form 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: [sectionId] }, { }
));
const cols = asRecords(await readTable(
req, activeDoc, '_grist_Tables_column', { parentId: [section.fields.tableRef] }, { }
));
const Tables = activeDoc.docData!.getMetaTable('_grist_Tables');
const Views_section_field = activeDoc.docData!.getMetaTable('_grist_Views_section_field');
const fields = Views_section_field.filterRecords({parentId: sectionId});
const Tables_column = activeDoc.docData!.getMetaTable('_grist_Tables_column');
// Read the box specs
const spec = section.fields.layoutSpec;
const spec = section.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);
const col = Tables_column.getRecord(f.colRef);
// Can't do attachments and formulas.
return col && !(col.fields.isFormula && col.fields.formula) && col.fields.type !== 'Attachment';
return col && !(col.isFormula && col.formula) && col.type !== 'Attachment';
});
box = {
type: 'Layout',
children: editable.map(f => ({
type: 'Field',
leaf: f.id
}))
children: [
{type: 'Label'},
{type: 'Label'},
{
type: 'Section',
children: [
{type: 'Label'},
{type: 'Label'},
...editable.slice(0, INITIAL_FIELDS_COUNT).map(f => ({
type: 'Field' as BoxType,
leaf: f.id
}))
]
}
],
};
box.children!.push({
type: 'Submit'
});
}
// Cache the table reads based on tableId. We are caching only the promise, not the result,
const table = _.memoize(
(tableId: string) => readTable(req, activeDoc, tableId, { }, { }).then(r => asRecords(r))
);
const readValues = async (tId: string, colId: string) => {
const records = await table(tId);
return records.map(r => [r.id as number, r.fields[colId]]);
};
const refValues = (col: MetaRowRecord<'_grist_Tables_column'>) => {
return async () => {
const refId = col.visibleCol;
if (!refId) { return [] as any; }
const refCol = Tables_column.getRecord(refId);
if (!refCol) { return []; }
const refTable = Tables.getRecord(refCol.parentId);
if (!refTable) { return []; }
const refTableId = refTable.tableId as string;
const refColId = refCol.colId as string;
if (!refTableId || !refColId) { return () => []; }
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
return await readValues(refTableId, refColId);
};
};
const context: RenderContext = {
field(fieldRef: number) {
const field = fields.find(f => f.id === fieldRef);
field(fieldRef: number): FieldModel {
const field = Views_section_field.getRecord(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 col = Tables_column.getRecord(field.colRef);
if (!col) { throw new Error(`Column ${field.colRef} not found`); }
const fieldOptions = safeJsonParse(field.widgetOptions as string, {});
const colOptions = safeJsonParse(col.widgetOptions as string, {});
const options = {...colOptions, ...fieldOptions};
const type = extractTypeFromColType(col.type as string);
const colId = col.colId as string;
return {
colId: col.fields.colId as string,
description: options.description,
question: options.question,
type: (col.fields.type as string).split(':')[0],
colId,
description: fieldOptions.description || col.description,
question: options.question || col.label || colId,
options,
type,
// If this is reference field, we will need to fetch the referenced table.
values: refValues(col)
};
}
},
root: box
};
// 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'});
let redirectUrl = !box.successURL ? '' : box.successURL;
// Make sure it is a valid URL.
try {
new URL(redirectUrl);
} catch (e) {
redirectUrl = '';
}
const html = await RenderBox.new(box, context).toHTML();
// 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(section.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)
);
const tableId = await getRealTableId(String(section.tableRef), {activeDoc, req});
const template = handlebars.compile(form);
const renderedHtml = template({
// Trusted content generated by us.
BASE: staticBaseUrl,
DOC_URL: await this._grist.getResourceUrl(doc, 'html'),
TABLE_ID: tableId,
ANOTHER_RESPONSE: Boolean(box.anotherResponse),
// Not trusted content entered by user.
CONTENT: html,
SUCCESS_TEXT: box.successText || `Thank you! Your response has been recorded.`,
SUCCESS_URL: redirectUrl,
});
res.status(200).send(renderedHtml);
})
);
}
@ -1501,7 +1551,7 @@ export class DocWorkerApi {
// Check that the request is for a valid section in the document.
const sections = docData.getMetaTable('_grist_Views_section');
const section = sections.getRecords().find(s => s.id === sectionId);
const section = sections.getRecord(sectionId);
if (!section) {
throw new ApiError('Form not found', 404);
}

@ -2116,9 +2116,11 @@ class UserActions(object):
title = ''
section = self._docmodel.add(view_sections, tableRef=tableRef, parentKey=section_type,
title=title, borderWidth=1, defaultWidth=100)[0]
# TODO: We should address the automatic selection of fields for charts in a better way.
# TODO: We should address the automatic selection of fields for charts
# and forms in a better way.
limit = 2 if section_type == 'chart' else 9 if section_type == 'form' else None
self._RebuildViewFields(tableId, section.id,
limit=(2 if section_type == 'chart' else None))
limit=limit)
return section
@useraction

@ -0,0 +1,434 @@
html,
body {
padding: 0px;
margin: 0px;
background-color: #f7f7f7;
line-height: 1.42857143;
}
* {
box-sizing: border-box;
}
.grist-form-container {
--icon-Tick: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+);
--icon-Minus: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=);
--primary: #16b378;
--primary-dark: #009058;
--dark-gray: #D9D9D9;
--light-gray: #bfbfbf;
--light: white;
color: #262633;
background-color: #f7f7f7;
min-height: 100%;
width: 100%;
padding-top: 52px;
font-size: 15px;
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;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.grist-form {
margin: 0px auto;
background-color: white;
border: 1px solid #E8E8E8;
width: 600px;
border-radius: 8px;
display: flex;
flex-direction: column;
max-width: calc(100% - 32px);
margin-bottom: 16px;
padding-top: 20px;
--grist-form-padding: 48px;
padding-left: var(--grist-form-padding);
padding-right: var(--grist-form-padding);
}
@media screen and (max-width: 600px) {
.grist-form-container {
padding-top: 20px;
}
.grist-form {
--grist-form-padding: 20px;
}
}
.grist-form > div + div {
margin-top: 16px;
}
.grist-form .grist-section {
border-radius: 3px;
border: 1px solid #D9D9D9;
padding: 16px 24px;
padding: 24px;
margin-top: 24px;
}
.grist-form .grist-section > div + div {
margin-top: 16px;
}
.grist-form input[type="text"],
.grist-form input[type="date"],
.grist-form input[type="datetime-local"],
.grist-form input[type="number"] {
padding: 4px 8px;
border: 1px solid #D9D9D9;
border-radius: 3px;
outline: none;
}
.grist-form .grist-field {
display: flex;
flex-direction: column;
}
.grist-form .grist-field .grist-field-description {
color: #222;
font-size: 12px;
font-weight: 400;
margin-top: 4px;
white-space: pre-wrap;
font-style: italic;
font-weight: 400;
line-height: 1.6;
}
.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%;
}
.grist-form .grist-submit, .grist-form-container button {
display: flex;
justify-content: center;
align-items: center;
}
.grist-form input[type="submit"], .grist-form-container button {
background-color: #16b378;
border: 1px solid #16b378;
color: white;
padding: 10px 24px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
line-height: inherit;
}
.grist-form input[type="datetime-local"] {
width: 100%;
line-height: inherit;
}
.grist-form input[type="date"] {
width: 100%;
line-height: inherit;
}
.grist-form input[type="checkbox"] {
margin: 0px;
}
.grist-form .grist-columns {
display: grid;
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
gap: 4px;
}
.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%;
}
.grist-form .grist-choice-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.grist-form .grist-checkbox {
display: flex;
align-items: center;
gap: 4px;
--color: var(--dark-gray);
}
.grist-form .grist-checkbox:hover {
--color: var(--light-gray);
}
.grist-form input[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
padding: 0;
flex-shrink: 0;
display: inline-block;
width: 16px;
height: 16px;
outline: none !important;
--radius: 3px;
position: relative;
margin: 0;
margin-right: 4px;
vertical-align: baseline;
}
.grist-form input[type="checkbox"]:checked:enabled, .grist-form input[type="checkbox"]:indeterminate:enabled {
--color: var(--primary);
}
.grist-form input[type="checkbox"]:disabled {
--color: var(--dark-gray);
cursor: not-allowed;
}
.grist-form input[type="checkbox"]::before, .grist-form input[type="checkbox"]::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 16px;
width: 16px;
box-sizing: border-box;
border: 1px solid var(--color, var(--dark-gray));
border-radius: var(--radius);
}
.grist-form input[type="checkbox"]:checked::before, .grist-form input[type="checkbox"]:disabled::before, .grist-form input[type="checkbox"]:indeterminate::before {
background-color: var(--color);
}
.grist-form input[type="checkbox"]:not(:checked):indeterminate::after {
-webkit-mask-image: var(--icon-Minus);
}
.grist-form input[type="checkbox"]:not(:disabled)::after {
background-color: var(--light);
}
.grist-form input[type="checkbox"]:checked::after, .grist-form input[type="checkbox"]:indeterminate::after {
content: '';
position: absolute;
height: 16px;
width: 16px;
-webkit-mask-image: var(--icon-Tick);
-webkit-mask-size: contain;
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
background-color: var(--light);
}
.grist-form .grist-submit input[type="submit"]:hover, .grist-form-container button:hover {
border-color: var(--primary-dark);
background-color: var(--primary-dark);
}
.grist-power-by {
margin-top: 24px;
color: var(--dark-text, #494949);
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid var(--dark-gray);
padding: 10px;
margin-left: calc(-1 * var(--grist-form-padding));
margin-right: calc(-1 * var(--grist-form-padding));
}
.grist-power-by a {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--dark-text, #494949);
text-decoration: none;
}
.grist-logo {
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(logo.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
}
.grist-question > .grist-label {
color: var(--dark, #262633);
font-size: 12px;
font-style: normal;
font-weight: 700;
line-height: 16px; /* 145.455% */
margin-bottom: 8px;
display: block;
}
/* Markdown reset */
.grist-form h1,
.grist-form h2,
.grist-form h3,
.grist-form h4,
.grist-form h5,
.grist-form h6 {
margin: 4px 0px;
font-weight: normal;
}
.grist-form h1 {
font-size: 24px;
}
.grist-form h2 {
font-size: 22px;
}
.grist-form h3 {
font-size: 16px;
}
.grist-form h4 {
font-size: 13px;
}
.grist-form h5 {
font-size: 11px;
}
.grist-form h6 {
font-size: 10px;
}
.grist-form p {
margin: 0px;
}
.grist-form strong {
font-weight: 600;
}
.grist-form hr {
border: 0px;
border-top: 1px solid var(--dark-gray);
margin: 4px 0px;
}
.grist-text-left {
text-align: left;
}
.grist-text-right {
text-align: right;
}
.grist-text-center {
text-align: center;
}
.grist-switch {
cursor: pointer;
display: flex;
align-items: center;
}
.grist-switch input[type='checkbox']::after {
content: none;
}
.grist-switch input[type='checkbox']::before {
content: none;
}
.grist-switch input[type='checkbox'] {
position: absolute;
}
.grist-switch > span {
margin-left: 8px;
}
/* Slider component */
.grist-widget_switch {
position: relative;
width: 30px;
height: 17px;
display: inline-block;
flex: none;
}
.grist-switch_slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--grist-theme-switch-slider-fg, #ccc);
border-radius: 17px;
}
.grist-switch_slider:hover {
box-shadow: 0 0 1px #2196F3;
}
.grist-switch_circle {
position: absolute;
cursor: pointer;
content: "";
height: 13px;
width: 13px;
left: 2px;
bottom: 2px;
background-color: var(--grist-theme-switch-circle-fg, white);
border-radius: 17px;
}
input:checked + .grist-switch_transition > .grist-switch_slider {
background-color: var(--primary, #16b378);
}
input:checked + .grist-switch_transition > .grist-switch_circle {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
.grist-switch_on > .grist-switch_slider {
background-color: var(--grist-actual-cell-color, #2CB0AF);
}
.grist-switch_on > .grist-switch_circle {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
.grist-switch_transition > .grist-switch_slider, .grist-switch_transition > .grist-switch_circle {
-webkit-transition: .4s;
transition: .4s;
}

@ -1,156 +1,64 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
{{#if BASE}}
<base href="{{ BASE }}">
{{/if}}
<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>
<link rel="stylesheet" href="forms/form.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
</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>
onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'flex', event.target.style.display = 'none'"
data-grist-doc="{{ DOC_URL }}"
data-grist-table="{{ TABLE_ID }}"
data-grist-success-url="{{ SUCCESS_URL }}"
>
{{ dompurify CONTENT }}
<div class="grist-power-by">
<a href="https://getgrist.com" target="_blank">
<div>Powered by</div>
<div class="grist-logo"></div>
</a>
</div>
</form>
<div class='grist-form-confirm' style='display: none'>
Thank you! Your response has been recorded.
<div>
{{ SUCCESS_TEXT }}
</div>
{{#if ANOTHER_RESPONSE }}
<button onclick="window.location.reload()">Submit another response</button>
{{/if}}
</div>
</main>
<script>
// Validate choice list on submit
document.querySelector('.grist-form input[type="submit"]').addEventListener('click', function(event) {
// When submit is pressed make sure that all choice lists that are required
// have at least one option selected
const choiceLists = document.querySelectorAll('.grist-choice-list.required:not(:has(input:checked))');
Array.from(choiceLists).forEach(function(choiceList) {
// If the form has at least one checkbox make it required
const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice lists with at least one option selected are no longer required
const choiceListsRequired = document.querySelectorAll('.grist-choice-list.required:has(input:checked)');
Array.from(choiceListsRequired).forEach(function(choiceList) {
// If the form has at least one checkbox make it required
const firstCheckbox = choiceList.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
});
</script>
</body>
</html>

@ -9,12 +9,13 @@ if (!window.gristFormSubmit) {
* - `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)`.
* - formElement is the form element that was submitted.
*
* 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) {
async function gristFormSubmit(docUrl, tableId, formData, formElement) {
// 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); }
@ -24,7 +25,7 @@ async function gristFormSubmit(docUrl, tableId, formData) {
// 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 payload = {records: [{fields: formDataToJson(formData, formElement)}]};
const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@ -58,6 +59,35 @@ function formDataToJson(f) {
k.endsWith('[]') ? [k.slice(0, -2), ['L', ...f.getAll(k)]] : [k, f.get(k)]));
}
/**
* TypedFormData is a wrapper around FormData that provides type information for the fields.
*/
class TypedFormData {
constructor(formElement, formData) {
if (!(formElement instanceof HTMLFormElement)) throw new Error("formElement must be a form");
if (formData && !(formData instanceof FormData)) throw new Error("formData must be a FormData");
this._formData = formData ?? new FormData(formElement);
this._formElement = formElement;
}
keys() { return this._formData.keys(); }
type(key) {
return this._formElement?.querySelector(`[name="${key}"]`)?.getAttribute('data-grist-type');
}
get(key) {
const value = this._formData.get(key);
if (value === null) { return null; }
const type = this.type(key);
return type === 'Ref' || type === 'RefList' ? Number(value) : value;
}
getAll(key) {
const values = Array.from(this._formData.getAll(key));
if (['Ref', 'RefList'].includes(this.type(key))) {
return values.map(v => Number(v));
}
return values;
}
}
// Handle submissions for plain forms that include special data-grist-* attributes.
async function handleSubmitPlainForm(ev) {
@ -76,7 +106,7 @@ async function handleSubmitPlainForm(ev) {
const successUrl = ev.target.getAttribute('data-grist-success-url');
await gristFormSubmit(docUrl, tableId, new FormData(ev.target));
await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target));
// On success, redirect to the requested URL.
if (successUrl) {
@ -111,7 +141,7 @@ async function handleSubmitWPCF7(ev) {
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));
await gristFormSubmit(docUrl, tableId, new TypedFormData(ev.target));
console.log("grist-form-submit WPCF7 Form %s: Added record", formId);
} catch (err) {
@ -135,7 +165,7 @@ async function handleSubmitGravityForm(ev, options) {
if (!docUrl) { throw new Error("setUpGravityForm: missing docUrl option"); }
if (!tableId) { throw new Error("setUpGravityForm: missing tableId option"); }
const f = new FormData(ev.target);
const f = new TypedFormData(ev.target);
for (const key of Array.from(f.keys())) {
// Skip fields other than input fields.
if (!key.startsWith("input_")) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@ -50,11 +50,13 @@
--icon-Chat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuNTYyIDNhNy41IDcuNSAwIDAgMC02Ljc5OCAxMC42NzNsLS43MjQgMi44NDJhMS4yNSAxLjI1IDAgMCAwIDEuNTA0IDEuNTI0Yy43NS0uMTggMS45MDMtLjQ1NyAyLjkzLS43MDJBNy41IDcuNSAwIDEgMCA5LjU2MSAzWm0tNiA3LjVhNiA2IDAgMSAxIDMuMzMgNS4zNzVsLS4yNDQtLjEyMS0uMjY0LjA2M2MtLjkyMy4yMi0xLjk5LjQ3NS0yLjc4OC42NjdsLjY5LTIuNzA4LjA3LS4yNzYtLjEzLS4yNTNhNS45NzEgNS45NzEgMCAwIDEtLjY2NC0yLjc0N1ptMTEgMTAuNWMtMS45NyAwLTMuNzYyLS43NTktNS4xLTJoLjFjLjcxOCAwIDEuNDE1LS4wODkgMi4wOC0uMjU3Ljg2NS40ODIgMS44Ni43NTcgMi45Mi43NTcuOTYgMCAxLjg2Ni0uMjI1IDIuNjctLjYyNWwuMjQzLS4xMjEuMjY0LjA2M2MuOTIyLjIyIDEuOTY2LjQ0NSAyLjc0LjYxLS4xNzUtLjc1MS0uNDE0LTEuNzU2LS42NDItMi42NTFsLS4wNy0uMjc2LjEzLS4yNTNhNS45NzEgNS45NzEgMCAwIDAgLjY2NS0yLjc0NyA1Ljk5NSA1Ljk5NSAwIDAgMC0yLjc0Ny01LjA0MiA4LjQ0IDguNDQgMCAwIDAtLjgtMi4wNDcgNy41MDMgNy41MDMgMCAwIDEgNC4zNDQgMTAuMjYzYy4yNTMgMS4wMDguNTA5IDIuMS42NzEgMi44MDNhMS4yNDQgMS4yNDQgMCAwIDEtMS40NjcgMS41IDEzMi42MiAxMzIuNjIgMCAwIDEtMi45MTMtLjY0IDcuNDc2IDcuNDc2IDAgMCAxLTMuMDg4LjY2M1oiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Code: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEwLjAzMzMyNzIsMC4zMjA1MTA0NiBDMTAuMTMyNDU2NSwwLjA2Mjc3NDE4OCAxMC40MjE3NTMzLC0wLjA2NTgwMjEzODYgMTAuNjc5NDg5NSwwLjAzMzMyNzE5NjkgQzEwLjkzNzIyNTgsMC4xMzI0NTY1MzIgMTEuMDY1ODAyMSwwLjQyMTc1MzI2NyAxMC45NjY2NzI4LDAuNjc5NDg5NTQgTDUuOTY2NjcyOCwxMy42Nzk0ODk1IEM1Ljg2NzU0MzQ3LDEzLjkzNzIyNTggNS41NzgyNDY3MywxNC4wNjU4MDIxIDUuMzIwNTEwNDYsMTMuOTY2NjcyOCBDNS4wNjI3NzQxOSwxMy44Njc1NDM1IDQuOTM0MTk3ODYsMTMuNTc4MjQ2NyA1LjAzMzMyNzIsMTMuMzIwNTEwNSBMMTAuMDMzMzI3MiwwLjMyMDUxMDQ2IFogTTMuODUzNTUzMzksOS4xNDY0NDY2MSBDNC4wNDg4MTU1NCw5LjM0MTcwODc2IDQuMDQ4ODE1NTQsOS42NTgyOTEyNCAzLjg1MzU1MzM5LDkuODUzNTUzMzkgQzMuNjU4MjkxMjQsMTAuMDQ4ODE1NSAzLjM0MTcwODc2LDEwLjA0ODgxNTUgMy4xNDY0NDY2MSw5Ljg1MzU1MzM5IEwwLjE0NjQ0NjYwOSw2Ljg1MzU1MzM5IEMtMC4wNDg4MTU1MzY1LDYuNjU4MjkxMjQgLTAuMDQ4ODE1NTM2NSw2LjM0MTcwODc2IDAuMTQ2NDQ2NjA5LDYuMTQ2NDQ2NjEgTDMuMTQ2NDQ2NjEsMy4xNDY0NDY2MSBDMy4zNDE3MDg3NiwyLjk1MTE4NDQ2IDMuNjU4MjkxMjQsMi45NTExODQ0NiAzLjg1MzU1MzM5LDMuMTQ2NDQ2NjEgQzQuMDQ4ODE1NTQsMy4zNDE3MDg3NiA0LjA0ODgxNTU0LDMuNjU4MjkxMjQgMy44NTM1NTMzOSwzLjg1MzU1MzM5IEwxLjIwNzEwNjc4LDYuNSBMMy44NTM1NTMzOSw5LjE0NjQ0NjYxIFogTTEyLjE0NjQ0NjYsMy44NTM1NTMzOSBDMTEuOTUxMTg0NSwzLjY1ODI5MTI0IDExLjk1MTE4NDUsMy4zNDE3MDg3NiAxMi4xNDY0NDY2LDMuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsMi45NTExODQ0NiAxMi42NTgyOTEyLDIuOTUxMTg0NDYgMTIuODUzNTUzNCwzLjE0NjQ0NjYxIEwxNS44NTM1NTM0LDYuMTQ2NDQ2NjEgQzE2LjA0ODgxNTUsNi4zNDE3MDg3NiAxNi4wNDg4MTU1LDYuNjU4MjkxMjQgMTUuODUzNTUzNCw2Ljg1MzU1MzM5IEwxMi44NTM1NTM0LDkuODUzNTUzMzkgQzEyLjY1ODI5MTIsMTAuMDQ4ODE1NSAxMi4zNDE3MDg4LDEwLjA0ODgxNTUgMTIuMTQ2NDQ2Niw5Ljg1MzU1MzM5IEMxMS45NTExODQ1LDkuNjU4MjkxMjQgMTEuOTUxMTg0NSw5LjM0MTcwODc2IDEyLjE0NjQ0NjYsOS4xNDY0NDY2MSBMMTQuNzkyODkzMiw2LjUgTDEyLjE0NjQ0NjYsMy44NTM1NTMzOSBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Collapse: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Columns: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU3OSkiPjxwYXRoIGQ9Ik0wIDJWMTQuMjM1M0gxNlYySDBaTTkuNDExNzYgMy44ODIzNVYxMi4zNTI5SDYuNTg4MjRWMy44ODIzNUg5LjQxMTc2Wk0xLjg4MjM1IDMuODgyMzVINC43MDU4OFYxMi4zNTI5SDEuODgyMzVWMy44ODIzNVpNMTQuMTE3NiAxMi4zNTI5SDExLjI5NDFWMy44ODIzNUgxNC4xMTc2VjEyLjM1MjlaIiBmaWxsPSIjMDAwIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMTA2MV83NTc5Ij48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');
--icon-Convert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzU2MDEzNDUsMTIuMjQzOTg2NSBMMiwxNCBMMiw5IEw2LjUsOS41IEw0LjQ2MzE0MTk4LDExLjUzNjg1OCBDNS41MTUxNDg5NCwxMi41ODk5NDY4IDYuOTk5MzIzODMsMTMuMTMzMjk5OCA4LjUxNTU5MDEzLDEyLjk3NjIzNTIgQzEwLjM3OTM1LDEyLjc4MzE3NDggMTEuOTc4NzQxMSwxMS41NjQyNzc0IDEyLjY1OTEyODYsOS44MTg0NDA2IEMxMi43NTk0MDExLDkuNTYxMTQ2OTIgMTMuMDQ5MjY1Nyw5LjQzMzg1NjA5IDEzLjMwNjU1OTQsOS41MzQxMjg1OCBDMTMuNTYzODUzMSw5LjYzNDQwMTA2IDEzLjY5MTE0MzksOS45MjQyNjU3MiAxMy41OTA4NzE0LDEwLjE4MTU1OTQgQzEyLjc3NDQwNjQsMTIuMjc2NTYzNiAxMC44NTUxMzcyLDEzLjczOTI0MDUgOC42MTg2MjUyOSwxMy45NzA5MTI5IEM2Ljc5OTI2MjMyLDE0LjE1OTM3NDMgNS4wMTg0MDI0MSwxMy41MDc0NzkxIDMuNzU2MDEzNDUsMTIuMjQzOTg2NSBaIE0xMi4yNDM5ODY1LDMuNzU2MDEzNDUgTDE0LDIgTDE0LDcgTDkuNSw2LjUgTDExLjUzNjg1OCw0LjQ2MzE0MTk4IEMxMC40ODQ4NTExLDMuNDEwMDUzMTggOS4wMDA2NzYxNywyLjg2NjcwMDE1IDcuNDg0NDA5ODcsMy4wMjM3NjQ4MyBDNS42MjA2NDk5NiwzLjIxNjgyNTE2IDQuMDIxMjU4OSw0LjQzNTcyMjU5IDMuMzQwODcxNDIsNi4xODE1NTk0IEMzLjI0MDU5ODk0LDYuNDM4ODUzMDggMi45NTA3MzQyOCw2LjU2NjE0MzkxIDIuNjkzNDQwNiw2LjQ2NTg3MTQyIEMyLjQzNjE0NjkyLDYuMzY1NTk4OTQgMi4zMDg4NTYwOSw2LjA3NTczNDI4IDIuNDA5MTI4NTgsNS44MTg0NDA2IEMzLjIyNTU5MzU1LDMuNzIzNDM2NDMgNS4xNDQ4NjI4MiwyLjI2MDc1OTUxIDcuMzgxMzc0NzEsMi4wMjkwODcxMiBDOS4yMDA3Mzc2OCwxLjg0MDYyNTcyIDEwLjk4MTU5NzYsMi40OTI1MjA5MiAxMi4yNDM5ODY1LDMuNzU2MDEzNDUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Copy: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLDMgTDExLDE1IEwyLDE1IEwyLDMgTDExLDMgWiBNMTAsMy45OTkgTDMsMy45OTkgTDMsMTMuOTk5IEwxMCwxMy45OTkgTDEwLDMuOTk5IFogTTE0LDAgTDE0LDEyIEwxMS41LDEyIEwxMS41LDExIEwxMywxMSBMMTMsMSBMNiwxIEw2LDIuNSBMNSwyLjUgTDUsMCBMMTQsMCBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-CrossBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjQxNDIxMzYsMTIgTDE5LjcwNzEwNjgsMTguMjkyODkzMiBDMjAuMDk3NjMxMSwxOC42ODM0MTc1IDIwLjA5NzYzMTEsMTkuMzE2NTgyNSAxOS43MDcxMDY4LDE5LjcwNzEwNjggQzE5LjMxNjU4MjUsMjAuMDk3NjMxMSAxOC42ODM0MTc1LDIwLjA5NzYzMTEgMTguMjkyODkzMiwxOS43MDcxMDY4IEwxMiwxMy40MTQyMTM2IEw1LjcwNzEwNjc4LDE5LjcwNzEwNjggQzUuMzE2NTgyNDksMjAuMDk3NjMxMSA0LjY4MzQxNzUxLDIwLjA5NzYzMTEgNC4yOTI4OTMyMiwxOS43MDcxMDY4IEMzLjkwMjM2ODkzLDE5LjMxNjU4MjUgMy45MDIzNjg5MywxOC42ODM0MTc1IDQuMjkyODkzMjIsMTguMjkyODkzMiBMMTAuNTg1Nzg2NCwxMiBMNC4yOTI4OTMyMiw1LjcwNzEwNjc4IEMzLjkwMjM2ODkzLDUuMzE2NTgyNDkgMy45MDIzNjg5Myw0LjY4MzQxNzUxIDQuMjkyODkzMjIsNC4yOTI4OTMyMiBDNC42ODM0MTc1MSwzLjkwMjM2ODkzIDUuMzE2NTgyNDksMy45MDIzNjg5MyA1LjcwNzEwNjc4LDQuMjkyODkzMjIgTDEyLDEwLjU4NTc4NjQgTDE4LjI5Mjg5MzIsNC4yOTI4OTMyMiBDMTguNjgzNDE3NSwzLjkwMjM2ODkzIDE5LjMxNjU4MjUsMy45MDIzNjg5MyAxOS43MDcxMDY4LDQuMjkyODkzMjIgQzIwLjA5NzYzMTEsNC42ODM0MTc1MSAyMC4wOTc2MzExLDUuMzE2NTgyNDkgMTkuNzA3MTA2OCw1LjcwNzEwNjc4IEwxMy40MTQyMTM2LDEyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-CrossSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEyLjUsNy41IEMxMi43NzYxNDI0LDcuNSAxMyw3LjcyMzg1NzYzIDEzLDggQzEzLDguMjc2MTQyMzcgMTIuNzc2MTQyNCw4LjUgMTIuNSw4LjUgTDguNSw4LjUgTDguNSwxMi41IEM4LjUsMTIuNzc2MTQyNCA4LjI3NjE0MjM3LDEzIDgsMTMgQzcuNzIzODU3NjMsMTMgNy41LDEyLjc3NjE0MjQgNy41LDEyLjUgTDcuNSw4LjUgTDMuNSw4LjUgQzMuMjIzODU3NjMsOC41IDMsOC4yNzYxNDIzNyAzLDggQzMsNy43MjM4NTc2MyAzLjIyMzg1NzYzLDcuNSAzLjUsNy41IEw3LjUsNy41IEw3LjUsMy41IEM3LjUsMy4yMjM4NTc2MyA3LjcyMzg1NzYzLDMgOCwzIEM4LjI3NjE0MjM3LDMgOC41LDMuMjIzODU3NjMgOC41LDMuNSBMOC41LDcuNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0icm90YXRlKC00NSA4IDgpIi8+PC9zdmc+');
--icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTIxMjEiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY2xhc3M9Im5jLWljb24td3JhcHBlciIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGVsbGlwc2UgY3g9IjgiIGN5PSIzIiByeD0iNi41IiByeT0iMi41IiBkYXRhLWNhcD0iYnV0dCIvPjxwYXRoIGQ9Ik0xLjUsNi41VjEzIGMwLDEuMzgxLDIuOTEsMi41LDYuNSwyLjVzNi41LTEuMTE5LDYuNS0yLjVWNi41IiBkYXRhLWNhcD0iYnV0dCIvPjwvZz48L3N2Zz4=');
--icon-Desktop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzYwMSkiPjxwYXRoIGQ9Ik0xNC41NDU1IDFIMS40NTQ1NUMwLjY1NDU0NSAxIDAgMS42NTQ1NSAwIDIuNDU0NTVWMTEuMTgxOEMwIDExLjk4MTggMC42NTQ1NDUgMTIuNjM2NCAxLjQ1NDU1IDEyLjYzNjRINi41NDU0NUw1LjA5MDkxIDE0LjgxODJWMTUuNTQ1NUgxMC45MDkxVjE0LjgxODJMOS40NTQ1NSAxMi42MzY0SDE0LjU0NTVDMTUuMzQ1NSAxMi42MzY0IDE2IDExLjk4MTggMTYgMTEuMTgxOFYyLjQ1NDU1QzE2IDEuNjU0NTUgMTUuMzQ1NSAxIDE0LjU0NTUgMVpNMTQuNTQ1NSA5LjcyNzI3SDEuNDU0NTVWMi40NTQ1NUgxNC41NDU1VjkuNzI3MjdaIiBmaWxsPSIjMDAwIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMTA2MV83NjAxIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');
--icon-Dots: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS41IEM3LjE3MTU3Mjg4LDkuNSA2LjUsOC44Mjg0MjcxMiA2LjUsOCBDNi41LDcuMTcxNTcyODggNy4xNzE1NzI4OCw2LjUgOCw2LjUgQzguODI4NDI3MTIsNi41IDkuNSw3LjE3MTU3Mjg4IDkuNSw4IEM5LjUsOC44Mjg0MjcxMiA4LjgyODQyNzEyLDkuNSA4LDkuNSBaIE0xMi41LDkuNSBDMTEuNjcxNTcyOSw5LjUgMTEsOC44Mjg0MjcxMiAxMSw4IEMxMSw3LjE3MTU3Mjg4IDExLjY3MTU3MjksNi41IDEyLjUsNi41IEMxMy4zMjg0MjcxLDYuNSAxNCw3LjE3MTU3Mjg4IDE0LDggQzE0LDguODI4NDI3MTIgMTMuMzI4NDI3MSw5LjUgMTIuNSw5LjUgWiBNMy41LDkuNSBDMi42NzE1NzI4OCw5LjUgMiw4LjgyODQyNzEyIDIsOCBDMiw3LjE3MTU3Mjg4IDIuNjcxNTcyODgsNi41IDMuNSw2LjUgQzQuMzI4NDI3MTIsNi41IDUsNy4xNzE1NzI4OCA1LDggQzUsOC44Mjg0MjcxMiA0LjMyODQyNzEyLDkuNSAzLjUsOS41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Download: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEyLDEzLjI5Mjg5MzIgTDE0LjE0NjQ0NjYsMTEuMTQ2NDQ2NiBDMTQuMzQxNzA4OCwxMC45NTExODQ1IDE0LjY1ODI5MTIsMTAuOTUxMTg0NSAxNC44NTM1NTM0LDExLjE0NjQ0NjYgQzE1LjA0ODgxNTUsMTEuMzQxNzA4OCAxNS4wNDg4MTU1LDExLjY1ODI5MTIgMTQuODUzNTUzNCwxMS44NTM1NTM0IEwxMS44NTM1NTM0LDE0Ljg1MzU1MzQgQzExLjY1ODI5MTIsMTUuMDQ4ODE1NSAxMS4zNDE3MDg4LDE1LjA0ODgxNTUgMTEuMTQ2NDQ2NiwxNC44NTM1NTM0IEw4LjE0NjQ0NjYxLDExLjg1MzU1MzQgQzcuOTUxMTg0NDYsMTEuNjU4MjkxMiA3Ljk1MTE4NDQ2LDExLjM0MTcwODggOC4xNDY0NDY2MSwxMS4xNDY0NDY2IEM4LjM0MTcwODc2LDEwLjk1MTE4NDUgOC42NTgyOTEyNCwxMC45NTExODQ1IDguODUzNTUzMzksMTEuMTQ2NDQ2NiBMMTEsMTMuMjkyODkzMiBMMTEsNy41IEMxMSw3LjIyMzg1NzYzIDExLjIyMzg1NzYsNyAxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw3LjIyMzg1NzYzIDEyLDcuNSBMMTIsMTMuMjkyODkzMiBaIE0xLjA4NTM1Mjg1LDExIEMxLjI5MTI3MTA2LDExLjU4MjU5NjIgMS44NDY4OTA1OSwxMiAyLjUsMTIgTDYuNSwxMiBDNi43NzYxNDIzNywxMiA3LDEyLjIyMzg1NzYgNywxMi41IEM3LDEyLjc3NjE0MjQgNi43NzYxNDIzNywxMyA2LjUsMTMgTDIuNSwxMyBDMS4xMTkyODgxMywxMyAxLjM4Nzc3ODc4ZS0xNiwxMS44ODA3MTE5IDAsMTAuNSBDMCwxMC4yMjM4NTc2IDAuMjIzODU3NjI1LDEwIDAuNSwxMCBMNi41LDEwIEM2Ljc3NjE0MjM3LDEwIDcsMTAuMjIzODU3NiA3LDEwLjUgQzcsMTAuNzc2MTQyNCA2Ljc3NjE0MjM3LDExIDYuNSwxMSBMMS4wODUzNTI4NSwxMSBaIE0yLDguNSBDMiw4Ljc3NjE0MjM3IDEuNzc2MTQyMzcsOSAxLjUsOSBDMS4yMjM4NTc2Myw5IDEsOC43NzYxNDIzNyAxLDguNSBMMSwyLjUgQzEsMS42NzE1NzI4OCAxLjY3MTU3Mjg4LDEgMi41LDEgTDEzLjUsMSBDMTQuMzI4NDI3MSwxIDE1LDEuNjcxNTcyODggMTUsMi41IEwxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBDMTQuMjIzODU3Niw5IDE0LDguNzc2MTQyMzcgMTQsOC41IEwxNCwyLjUgQzE0LDIuMjIzODU3NjMgMTMuNzc2MTQyNCwyIDEzLjUsMiBMMi41LDIgQzIuMjIzODU3NjMsMiAyLDIuMjIzODU3NjMgMiwyLjUgTDIsOC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-DragDrop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNSwzIEM2Ljc3NjE0MjM3LDMgNywzLjIyMzg1NzYzIDcsMy41IEw3LDEyLjUgQzcsMTIuNzc2MTQyNCA2Ljc3NjE0MjM3LDEzIDYuNSwxMyBDNi4yMjM4NTc2MywxMyA2LDEyLjc3NjE0MjQgNiwxMi41IEw2LDMuNSBDNiwzLjIyMzg1NzYzIDYuMjIzODU3NjMsMyA2LjUsMyBaIE05LjUsMyBDOS43NzYxNDIzNywzIDEwLDMuMjIzODU3NjMgMTAsMy41IEwxMCwxMi41IEMxMCwxMi43NzYxNDI0IDkuNzc2MTQyMzcsMTMgOS41LDEzIEM5LjIyMzg1NzYzLDEzIDksMTIuNzc2MTQyNCA5LDEyLjUgTDksMy41IEM5LDMuMjIzODU3NjMgOS4yMjM4NTc2MywzIDkuNSwzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
@ -95,6 +97,7 @@
--icon-Message: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE1LjUgN0MxNS41IDMuNDEgMTIuMTQyIDAuNSA4IDAuNUMzLjg1OCAwLjUgMC41IDMuNDEgMC41IDdDMC41IDEwLjU5IDMuODU4IDEzLjUgOCAxMy41QzguNTI1IDEzLjUgOS4wMzcgMTMuNDUyIDkuNTMyIDEzLjM2M0wxMy41IDE1LjVWMTEuNDA5QzE0LjczOCAxMC4yNSAxNS41IDguNzA0IDE1LjUgN1oiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-Minimize: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzI0MF83MjAwKSIgc3Ryb2tlPSIjZmZmIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNC41IDYuNUg5LjVWMS41TTE1LjUuNUw5LjUgNi41TTYuNSAxNC41VjkuNUgxLjVNLjUgMTUuNUw2LjUgOS41Ii8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMjQwXzcyMDAiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');
--icon-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=');
--icon-Mobile: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU5OCkiPjxwYXRoIGQ9Ik0xMiAwSDRDMy40Njk1NyAwIDIuOTYwODYgMC4yMTA3MTQgMi41ODU3OSAwLjU4NTc4NkMyLjIxMDcxIDAuOTYwODU5IDIgMS40Njk1NyAyIDJWMTRDMiAxNC41MzA0IDIuMjEwNzEgMTUuMDM5MSAyLjU4NTc5IDE1LjQxNDJDMi45NjA4NiAxNS43ODkzIDMuNDY5NTcgMTYgNCAxNkgxMkMxMi41MzA0IDE2IDEzLjAzOTEgMTUuNzg5MyAxMy40MTQyIDE1LjQxNDJDMTMuNzg5MyAxNS4wMzkxIDE0IDE0LjUzMDQgMTQgMTRWMkMxNCAxLjQ2OTU3IDEzLjc4OTMgMC45NjA4NTkgMTMuNDE0MiAwLjU4NTc4NkMxMy4wMzkxIDAuMjEwNzE0IDEyLjUzMDQgMCAxMiAwWk00IDEzVjNIMTJWMTNINFoiIGZpbGw9IiMwMDAiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwMF8xMDYxXzc1OTgiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');
--icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
--icon-NewNotification: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIGN4PSI4IiBjeT0iOCIgcj0iMyIvPjwvc3ZnPg==');
@ -103,6 +106,7 @@
--icon-Page: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIsMSBMMiwxNSBMMTQsMTUgTDE0LDEgTDIsMSBaIE0xLjUsMCBMMTQuNSwwIEMxNC43NzYxNDI0LDAgMTUsMC4yMjM4NTc2MjUgMTUsMC41IEwxNSwxNS41IEMxNSwxNS43NzYxNDI0IDE0Ljc3NjE0MjQsMTYgMTQuNSwxNiBMMS41LDE2IEMxLjIyMzg1NzYzLDE2IDEsMTUuNzc2MTQyNCAxLDE1LjUgTDEsMC41IEMxLDAuMjIzODU3NjI1IDEuMjIzODU3NjMsMCAxLjUsMCBaIE05LjUsNCBDOS4yMjM4NTc2Myw0IDksMy43NzYxNDIzNyA5LDMuNSBDOSwzLjIyMzg1NzYzIDkuMjIzODU3NjMsMyA5LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMOS41LDQgWiBNOS41LDcgQzkuMjIzODU3NjMsNyA5LDYuNzc2MTQyMzcgOSw2LjUgQzksNi4yMjM4NTc2MyA5LjIyMzg1NzYzLDYgOS41LDYgTDExLjUsNiBDMTEuNzc2MTQyNCw2IDEyLDYuMjIzODU3NjMgMTIsNi41IEMxMiw2Ljc3NjE0MjM3IDExLjc3NjE0MjQsNyAxMS41LDcgTDkuNSw3IFogTTQuNSwxMCBDNC4yMjM4NTc2MywxMCA0LDkuNzc2MTQyMzcgNCw5LjUgQzQsOS4yMjM4NTc2MyA0LjIyMzg1NzYzLDkgNC41LDkgTDExLjUsOSBDMTEuNzc2MTQyNCw5IDEyLDkuMjIzODU3NjMgMTIsOS41IEMxMiw5Ljc3NjE0MjM3IDExLjc3NjE0MjQsMTAgMTEuNSwxMCBMNC41LDEwIFogTTQuNSwxMyBDNC4yMjM4NTc2MywxMyA0LDEyLjc3NjE0MjQgNCwxMi41IEM0LDEyLjIyMzg1NzYgNC4yMjM4NTc2MywxMiA0LjUsMTIgTDExLjUsMTIgQzExLjc3NjE0MjQsMTIgMTIsMTIuMjIzODU3NiAxMiwxMi41IEMxMiwxMi43NzYxNDI0IDExLjc3NjE0MjQsMTMgMTEuNSwxMyBMNC41LDEzIFogTTUsNCBMNSw2IEw3LDYgTDcsNCBMNSw0IFogTTQuNSwzIEw3LjUsMyBDNy43NzYxNDIzNywzIDgsMy4yMjM4NTc2MyA4LDMuNSBMOCw2LjUgQzgsNi43NzYxNDIzNyA3Ljc3NjE0MjM3LDcgNy41LDcgTDQuNSw3IEM0LjIyMzg1NzYzLDcgNCw2Ljc3NjE0MjM3IDQsNi41IEw0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-PanelLeft: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzA3MTA2NzgsOC41IEw1Ljg1MzU1MzM5LDEwLjY0NjQ0NjYgQzYuMDQ4ODE1NTQsMTAuODQxNzA4OCA2LjA0ODgxNTU0LDExLjE1ODI5MTIgNS44NTM1NTMzOSwxMS4zNTM1NTM0IEM1LjY1ODI5MTI0LDExLjU0ODgxNTUgNS4zNDE3MDg3NiwxMS41NDg4MTU1IDUuMTQ2NDQ2NjEsMTEuMzUzNTUzNCBMMi4xNDY0NDY2MSw4LjM1MzU1MzM5IEMxLjk1MTE4NDQ2LDguMTU4MjkxMjQgMS45NTExODQ0Niw3Ljg0MTcwODc2IDIuMTQ2NDQ2NjEsNy42NDY0NDY2MSBMNS4xNDY0NDY2MSw0LjY0NjQ0NjYxIEM1LjM0MTcwODc2LDQuNDUxMTg0NDYgNS42NTgyOTEyNCw0LjQ1MTE4NDQ2IDUuODUzNTUzMzksNC42NDY0NDY2MSBDNi4wNDg4MTU1NCw0Ljg0MTcwODc2IDYuMDQ4ODE1NTQsNS4xNTgyOTEyNCA1Ljg1MzU1MzM5LDUuMzUzNTUzMzkgTDMuNzA3MTA2NzgsNy41IEw4LjUsNy41IEM4Ljc3NjE0MjM3LDcuNSA5LDcuNzIzODU3NjMgOSw4IEM5LDguMjc2MTQyMzcgOC43NzYxNDIzNyw4LjUgOC41LDguNSBMMy43MDcxMDY3OCw4LjUgWiBNMTAsMTMuNSBMMTAsMi41IEMxMCwyLjIyMzg1NzYzIDEwLjIyMzg1NzYsMiAxMC41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEwxNCwxMy41IEMxNCwxMy43NzYxNDI0IDEzLjc3NjE0MjQsMTQgMTMuNSwxNCBMMTAuNSwxNCBDMTAuMjIzODU3NiwxNCAxMCwxMy43NzYxNDI0IDEwLDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PanelRight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzA3MTA2NzgsOC41IEw1Ljg1MzU1MzM5LDEwLjY0NjQ0NjYgQzYuMDQ4ODE1NTQsMTAuODQxNzA4OCA2LjA0ODgxNTU0LDExLjE1ODI5MTIgNS44NTM1NTMzOSwxMS4zNTM1NTM0IEM1LjY1ODI5MTI0LDExLjU0ODgxNTUgNS4zNDE3MDg3NiwxMS41NDg4MTU1IDUuMTQ2NDQ2NjEsMTEuMzUzNTUzNCBMMi4xNDY0NDY2MSw4LjM1MzU1MzM5IEMxLjk1MTE4NDQ2LDguMTU4MjkxMjQgMS45NTExODQ0Niw3Ljg0MTcwODc2IDIuMTQ2NDQ2NjEsNy42NDY0NDY2MSBMNS4xNDY0NDY2MSw0LjY0NjQ0NjYxIEM1LjM0MTcwODc2LDQuNDUxMTg0NDYgNS42NTgyOTEyNCw0LjQ1MTE4NDQ2IDUuODUzNTUzMzksNC42NDY0NDY2MSBDNi4wNDg4MTU1NCw0Ljg0MTcwODc2IDYuMDQ4ODE1NTQsNS4xNTgyOTEyNCA1Ljg1MzU1MzM5LDUuMzUzNTUzMzkgTDMuNzA3MTA2NzgsNy41IEw4LjUsNy41IEM4Ljc3NjE0MjM3LDcuNSA5LDcuNzIzODU3NjMgOSw4IEM5LDguMjc2MTQyMzcgOC43NzYxNDIzNyw4LjUgOC41LDguNSBMMy43MDcxMDY3OCw4LjUgWiBNMTAsMTMuNSBMMTAsMi41IEMxMCwyLjIyMzg1NzYzIDEwLjIyMzg1NzYsMiAxMC41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEwxNCwxMy41IEMxNCwxMy43NzYxNDI0IDEzLjc3NjE0MjQsMTQgMTMuNSwxNCBMMTAuNSwxNCBDMTAuMjIzODU3NiwxNCAxMCwxMy43NzYxNDI0IDEwLDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB0cmFuc2Zvcm09Im1hdHJpeCgtMSAwIDAgMSAxNiAwKSIvPjwvc3ZnPg==');
--icon-Paragraph: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU4MikiPjxwYXRoIGQ9Ik01LjkyMzA4IDIuNDYxNTRWNy4zODQ2MkM0LjU2OTIzIDcuMzg0NjIgMy40NjE1NCA2LjI3NjkyIDMuNDYxNTQgNC45MjMwOEMzLjQ2MTU0IDMuNTY5MjMgNC41NjkyMyAyLjQ2MTU0IDUuOTIzMDggMi40NjE1NFpNMTQgMEg1LjkyMzA4QzMuMjAzMDggMCAxIDIuMjAzMDggMSA0LjkyMzA4QzEgNy42NDMwOCAzLjIwMzA4IDkuODQ2MTUgNS45MjMwOCA5Ljg0NjE1VjE2SDcuNVYyLjQ2MTU0SDkuNTk2MTVWMTZIMTEuMjVMMTEuMjUgMi40NjE1NEgxNFYwWiIgZmlsbD0iIzAwMCIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzEwNjFfNzU4MiI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
--icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9Ii0yIC0yIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMyAuNUwxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNUwxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4=');
--icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjgyOTcyODEsLTcuNjAyODA3MjdlLTEzIEw0LjE5Mjk3NDkxLC03LjU3NjE2MTkyZS0xMyBDMy42MjcyODk0OCwtNy41MzE3NTNlLTEzIDMuMjAzMDI1NDEsMC40MTAyNTY0MSAzLjIwMzAyNTQxLDAuOTU3MjY0OTU3IEMzLjIwMzAyNTQxLDEuNTA0MjczNSAzLjYyNzI4OTQ4LDEuOTE0NTI5OTEgNC4xOTI5NzQ5MSwxLjkxNDUyOTkxIEw1LjA0MTUwMzA0LDEuOTE0NTI5OTEgTDIuODQ5NDcyMDIsOS4wOTQwMTcwOSBMMS40MzUyNTg0Niw5LjA5NDAxNzA5IEMwLjg2OTU3MzAzNiw5LjA5NDAxNzA5IDAuNDQ1MzA4OTY3LDkuNTA0MjczNSAwLjQ0NTMwODk2NywxMC4wNTEyODIxIEMwLjQ0NTMwODk2NywxMC41OTgyOTA2IDAuODY5NTczMDM2LDExLjAwODU0NyAxLjQzNTI1ODQ2LDExLjAwODU0NyBMMy41NTY1Nzg4LDExLjAwODU0NyBMNi45NTA2OTEzNSwxMS4wMDg1NDcgTDcuMDIxNDAyMDMsMTYgTDkuMDAxMzAxMDIsMTYgTDkuMDAxMzAxMDIsMTAuOTQwMTcwOSBMMTQuNjU4MTU1MywxMC45NDAxNzA5IEMxNS4zNjUyNjIxLDEwLjk0MDE3MDkgMTUuNjQ4MTA0OCwxMC4yNTY0MTAzIDE1LjY0ODEwNDgsOS45ODI5MDU5OCBDMTUuNjQ4MTA0OCw5LjQzNTg5NzQ0IDE1LjIyMzg0MDcsOS4wMjU2NDEwMyAxNC42NTgxNTUzLDkuMDI1NjQxMDMgTDEzLjI0Mzk0MTcsOS4wMjU2NDEwMyBMMTEuMTIyNjIxNCwxLjkxNDUyOTkxIEwxMS45NzExNDk1LDEuOTE0NTI5OTEgQzEyLjgxOTY3NzYsMS45MTQ1Mjk5MSAxMi45NjEwOTksMS4yMzA3NjkyMyAxMi45NjEwOTksMC45NTcyNjQ5NTcgQzEyLjg5MDM4ODMsMC40Nzg2MzI0NzkgMTIuMzk1NDEzNiwtNy42NjQ5Nzk3NmUtMTMgMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgWiBNMTEuMDUxOTEwNyw5LjA5NDAxNzA5IEw0Ljk3MDc5MjM3LDkuMDk0MDE3MDkgTDcuMDkyMTEyNzEsMS45ODI5MDU5OCBMOC45MzA1OTAzNCwxLjk4MjkwNTk4IEwxMS4wNTE5MTA3LDkuMDk0MDE3MDkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PinSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSwxMSBMMi41LDExIEMyLjIyMzg1NzYzLDExIDIsMTAuNzc2MTQyNCAyLDEwLjUgQzIsMTAuMjIzODU3NiAyLjIyMzg1NzYzLDEwIDIuNSwxMCBMNC4yNSwxMCBMNS43NSw0IEw0LjUsNCBDNC4yMjM4NTc2Myw0IDQsMy43NzYxNDIzNyA0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMMTAuMjUsNCBMMTEuNzUsMTAgTDEzLjUsMTAgQzEzLjc3NjE0MjQsMTAgMTQsMTAuMjIzODU3NiAxNCwxMC41IEMxNCwxMC43NzYxNDI0IDEzLjc3NjE0MjQsMTEgMTMuNSwxMSBMOC41LDExIEw4LjUsMTQgTDcuNSwxNCBMNy41LDExIFogTTUuMjgwNzc2NDEsMTAgTDEwLjcxOTIyMzYsMTAgTDkuMjE5MjIzNTksNCBMNi43ODA3NzY0MSw0IEw1LjI4MDc3NjQxLDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
@ -124,6 +128,8 @@
--icon-Robot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2IDJWMEgxMFYySDEyVjRINFYySDZWMEgwVjJIMlY0SDBWMTZIMTZWNEgxNFYySDE2Wk0xMCA3QzEwLjYgNyAxMSA3LjQgMTEgOEMxMSA4LjYgMTAuNiA5IDEwIDlDOS40IDkgOSA4LjYgOSA4QzkgNy40IDkuNCA3IDEwIDdaTTYgN0M2LjYgNyA3IDcuNCA3IDhDNyA4LjYgNi42IDkgNiA5QzUuNCA5IDUgOC42IDUgOEM1IDcuNCA1LjQgNyA2IDdaTTEyIDEzSDRWMTFIMTJWMTNaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4=');
--icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Section: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU5MykiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjMDAwIj48cGF0aCBkPSJNMi4xMDUyNiA0LjIxMDk0SDEzLjg5NDdDMTUuMDU3NCA0LjIxMDk0IDE2IDUuMTUzNSAxNiA2LjMxNjJWOS42ODQ2MkMxNiAxMC44NDczIDE1LjA1NzQgMTEuNzg5OSAxMy44OTQ3IDExLjc4OTlIMi4xMDUyNkMuOTQyNTU4IDExLjc4OTkgMCAxMC44NDczIDAgOS42ODQ2MlY2LjMxNjJDMCA1LjE1MzUuOTQyNTU4IDQuMjEwOTQgMi4xMDUyNiA0LjIxMDk0ek0yLjEwNTI2IDUuNDc0MUMxLjY0MDE4IDUuNDc0MSAxLjI2MzE2IDUuODUxMTIgMS4yNjMxNiA2LjMxNjJWOS42ODQ2MkMxLjI2MzE2IDEwLjE0OTcgMS42NDAxOCAxMC41MjY3IDIuMTA1MjYgMTAuNTI2N0gxMy44OTQ3QzE0LjM1OTggMTAuNTI2NyAxNC43MzY4IDEwLjE0OTcgMTQuNzM2OCA5LjY4NDYyVjYuMzE2MkMxNC43MzY4IDUuODUxMTIgMTQuMzU5OCA1LjQ3NDEgMTMuODk0NyA1LjQ3NDFIMi4xMDUyNnpNMS4yNjMxNiAxNi4wMDAxSDBWMTUuMTU4TC4wMDAwNDMwNjU5IDE1LjE0NDRDLjAwNzM2NzA4IDEzLjk4NzkuOTQ3MSAxMy4wNTI3IDIuMTA1MjYgMTMuMDUyN0gzLjU3ODk1VjE0LjMxNTlIMi4xMDUyNkMxLjY0MDE4IDE0LjMxNTkgMS4yNjMxNiAxNC42OTI5IDEuMjYzMTYgMTUuMTU4VjE2LjAwMDF6TTkuNDczNjggMTQuMzE1OUg2LjUyNjMyVjEzLjA1MjdIOS40NzM2OFYxNC4zMTU5ek0xNC43MzY4IDE2LjAwMDFWMTUuMTU4QzE0LjczNjggMTQuNjkyOSAxNC4zNTk4IDE0LjMxNTkgMTMuODk0NyAxNC4zMTU5SDEyLjQyMTFWMTMuMDUyN0gxMy44OTQ3QzE1LjA1NzQgMTMuMDUyNyAxNiAxMy45OTUzIDE2IDE1LjE1OFYxNi4wMDAxSDE0LjczNjh6TTE0LjczNjgtLjAwMDEwMjgxOEgxNlYuODQyMDAyTDE2IC44NTU2MThDMTUuOTkyNiAyLjAxMjA2IDE1LjA1MjkgMi45NDcyNyAxMy44OTQ3IDIuOTQ3MjdIMTIuNDIxMVYxLjY4NDExTDEzLjg5NDcgMS42ODQxMUMxNC4zNTk4IDEuNjg0MTEgMTQuNzM2OCAxLjMwNzA4IDE0LjczNjguODQyMDAyVi0uMDAwMTAyODE4ek02LjUyNjMyIDEuNjg0MTFMOS40NzM2OCAxLjY4NDExVjIuOTQ3MjdMNi41MjYzMiAyLjk0NzI3VjEuNjg0MTF6TTEuMjYzMTYtLjAwMDEwMjgxOFYuODQyMDAyQzEuMjYzMTYgMS4zMDcwOCAxLjY0MDE4IDEuNjg0MTEgMi4xMDUyNiAxLjY4NDExTDMuNTc4OTUgMS42ODQxMVYyLjk0NzI3TDIuMTA1MjYgMi45NDcyN0MuOTQyNTU4IDIuOTQ3MjctMS4xOTIwOWUtNyAyLjAwNDcxLTEuMTkyMDllLTcuODQyMDAyVi0uMDAwMTAyODE4SDEuMjYzMTZ6Ii8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfMTA2MV83NTkzIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');
--icon-Separator: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzEwNjFfNzU4OCkiIGZpbGw9IiMwMDAiPjxwYXRoIGQ9Ik0xLjMzMyA3SDE0LjY2NlY4LjMzM0gxLjMzM3pNMCA1SDEuMzMzVjEwLjMzM0gwek0xNC42NjcgNUgxNlYxMC4zMzNIMTQuNjY3eiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwXzEwNjFfNzU4OCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');
--icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Share: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNzc0MTEwMDUsOS4xNDQxMzAyOSBMOS43NTY4Nzg3NiwxMS4wMDc4NjUxIEMxMC4zMDY0MDY1LDEwLjM4OTU1MjMgMTEuMTA3NzEwOSwxMCAxMiwxMCBDMTMuNjU2ODU0MiwxMCAxNSwxMS4zNDMxNDU4IDE1LDEzIEMxNSwxNC42NTY4NTQyIDEzLjY1Njg1NDIsMTYgMTIsMTYgQzEwLjM0MzE0NTgsMTYgOSwxNC42NTY4NTQyIDksMTMgQzksMTIuNTk0NjU3MiA5LjA4MDM4OTUzLDEyLjIwODA5MDQgOS4yMjYwOTUwNywxMS44NTUzNzMgTDYuMjQzNDc4MjQsOS45OTE3MzMxNyBDNS42OTM5NDA1NSwxMC42MTAyNzkgNC44OTI0ODIzOSwxMSA0LDExIEMyLjM0MzE0NTc1LDExIDEsOS42NTY4NTQyNSAxLDggQzEsNi4zNDMxNDU3NSAyLjM0MzE0NTc1LDUgNCw1IEM0Ljg5MjQ4MjM5LDUgNS42OTM5NDA1NSw1LjM4OTcyMTAzIDYuMjQzNDc4MjQsNi4wMDgyNjY4MyBMOS4yMjYwOTUwNyw0LjE0NDYyNjk2IEM5LjA4MDM4OTUzLDMuNzkxOTA5NjMgOSwzLjQwNTM0MjggOSwzIEM5LDEuMzQzMTQ1NzUgMTAuMzQzMTQ1OCwwIDEyLDAgQzEzLjY1Njg1NDIsMCAxNSwxLjM0MzE0NTc1IDE1LDMgQzE1LDQuNjU2ODU0MjUgMTMuNjU2ODU0Miw2IDEyLDYgQzExLjEwNzcxMDksNiAxMC4zMDY0MDY1LDUuNjEwNDQ3NzMgOS43NTY4Nzg3Niw0Ljk5MjEzNDkzIEw2Ljc3NDExMDA1LDYuODU1ODY5NzEgQzYuOTE5Njg1OTIsNy4yMDg0NTMyNSA3LDcuNTk0ODQ3NDQgNyw4IEM3LDguNDA1MTUyNTYgNi45MTk2ODU5Miw4Ljc5MTU0Njc1IDYuNzc0MTEwMDUsOS4xNDQxMzAyOSBaIE01LjcwNDEyNDg1LDkuMDQ3NDE3MTYgQzUuODkxNzYwNjYsOC43NDI3ODczNCA2LDguMzg0MDM0IDYsOCBDNiw3LjYxNTk2NiA1Ljg5MTc2MDY2LDcuMjU3MjEyNjYgNS43MDQxMjQ4NSw2Ljk1MjU4Mjg0IEM1LjcwMTM1MzQ2LDYuOTQ4NDI1NzcgNS42OTg2MzQ0Miw2Ljk0NDIxNDMyIDUuNjk1OTY5MTgsNi45Mzk5NDg4IEM1LjY5MzQ2MjQ3LDYuOTM1OTM2OTkgNS42OTEwMTk1OSw2LjkzMTkwMzM4IDUuNjg4NjQwMjksNi45Mjc4NDkxNSBDNS4zMzM3NDk5MSw2LjM3MDA2MTY1IDQuNzEwMDkxMjIsNiA0LDYgQzIuODk1NDMwNSw2IDIsNi44OTU0MzA1IDIsOCBDMiw5LjEwNDU2OTUgMi44OTU0MzA1LDEwIDQsMTAgQzQuNzEwMDkxMjIsMTAgNS4zMzM3NDk5MSw5LjYyOTkzODM1IDUuNjg4NjQwMjksOS4wNzIxNTA4NSBDNS42OTEwMTk1OSw5LjA2ODA5NjYyIDUuNjkzNDYyNDcsOS4wNjQwNjMwMSA1LjY5NTk2OTE4LDkuMDYwMDUxMiBDNS42OTg2MzQ0Miw5LjA1NTc4NTY4IDUuNzAxMzUzNDYsOS4wNTE1NzQyMyA1LjcwNDEyNDg1LDkuMDQ3NDE3MTYgWiBNMTAuMzE5OTA5MiwxMS45MTQ1MjgzIEMxMC4zMTUyMjY4LDExLjkyMzA4OTcgMTAuMzEwMjY4MSwxMS45MzE1NjY5IDEwLjMwNTAzMDgsMTEuOTM5OTQ4OCBDMTAuMjk5NTk2LDExLjk0ODY0NjggMTAuMjkzOTM3NiwxMS45NTcxMTk5IDEwLjI4ODA2NzYsMTEuOTY1MzY1NSBDMTAuMTA1MjQsMTIuMjY3MjI4NiAxMCwxMi42MjEzMjQzIDEwLDEzIEMxMCwxNC4xMDQ1Njk1IDEwLjg5NTQzMDUsMTUgMTIsMTUgQzEzLjEwNDU2OTUsMTUgMTQsMTQuMTA0NTY5NSAxNCwxMyBDMTQsMTEuODk1NDMwNSAxMy4xMDQ1Njk1LDExIDEyLDExIEMxMS4yOTU1NzY3LDExIDEwLjY3NjIxMTcsMTEuMzY0MTc3NSAxMC4zMTk5MDkyLDExLjkxNDUyODMgWiBNMTAuMjg4MDY3Niw0LjAzNDYzNDU0IEMxMC4yOTM5Mzc2LDQuMDQyODgwMDkgMTAuMjk5NTk2LDQuMDUxMzUzMjUgMTAuMzA1MDMwOCw0LjA2MDA1MTIgQzEwLjMxMDI2ODEsNC4wNjg0MzMxNSAxMC4zMTUyMjY4LDQuMDc2OTEwMjUgMTAuMzE5OTA5Miw0LjA4NTQ3MTY4IEMxMC42NzYyMTE3LDQuNjM1ODIyNDUgMTEuMjk1NTc2Nyw1IDEyLDUgQzEzLjEwNDU2OTUsNSAxNCw0LjEwNDU2OTUgMTQsMyBDMTQsMS44OTU0MzA1IDEzLjEwNDU2OTUsMSAxMiwxIEMxMC44OTU0MzA1LDEgMTAsMS44OTU0MzA1IDEwLDMgQzEwLDMuMzc4Njc1NzQgMTAuMTA1MjQsMy43MzI3NzEzNyAxMC4yODgwNjc2LDQuMDM0NjM0NTQgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Sort: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTk5MjUsMy43MDY1NTM3NyBMMy45OTkyNSwxMy40OTkyNSBDMy45OTkyNSwxMy43NzUyNSAzLjc3NTI1LDEzLjk5OTI1IDMuNDk5MjUsMTMuOTk5MjUgQzMuMjIzMjUsMTMuOTk5MjUgMi45OTkyNSwxMy43NzUyNSAyLjk5OTI1LDEzLjQ5OTI1IEwyLjk5OTI1LDMuNzA2NTUzNzcgTDEuODUzMjUsNC44NTMyNSBDMS42NTgyNSw1LjA0ODI1IDEuMzQxMjUsNS4wNDgyNSAxLjE0NjI1LDQuODUzMjUgQzAuOTUxMjUsNC42NTgyNSAwLjk1MTI1LDQuMzQxMjUgMS4xNDYyNSw0LjE0NjI1IEwzLjE0NjI1LDIuMTQ2MjUgQzMuMzQxMjUsMS45NTEyNSAzLjY1ODI1LDEuOTUxMjUgMy44NTMyNSwyLjE0NjI1IEw1Ljg1MzI1LDQuMTQ2MjUgQzYuMDQ4MjUsNC4zNDEyNSA2LjA0ODI1LDQuNjU4MjUgNS44NTMyNSw0Ljg1MzI1IEM1Ljc1NTI1LDQuOTUwMjUgNS42MjcyNSw0Ljk5OTI1IDUuNDk5MjUsNC45OTkyNSBDNS4zNzEyNSw0Ljk5OTI1IDUuMjQzMjUsNC45NTAyNSA1LjE0NTI1LDQuODUzMjUgTDMuOTk5MjUsMy43MDY1NTM3NyBaIE04LjQ5OTI1LDMuOTk5MjUgTDcuNDk5MjUsMy45OTkyNSBDNy4yMjMyNSwzLjk5OTI1IDYuOTk5MjUsMy43NzUyNSA2Ljk5OTI1LDMuNDk5MjUgQzYuOTk5MjUsMy4yMjMyNSA3LjIyMzI1LDIuOTk5MjUgNy40OTkyNSwyLjk5OTI1IEw4LjQ5OTI1LDIuOTk5MjUgQzguNzc1MjUsMi45OTkyNSA4Ljk5OTI1LDMuMjIzMjUgOC45OTkyNSwzLjQ5OTI1IEM4Ljk5OTI1LDMuNzc1MjUgOC43NzUyNSwzLjk5OTI1IDguNDk5MjUsMy45OTkyNSBaIE0xMC40OTkyNSw2Ljk5OTI1IEw3LjQ5OTI1LDYuOTk5MjUgQzcuMjIzMjUsNi45OTkyNSA2Ljk5OTI1LDYuNzc1MjUgNi45OTkyNSw2LjQ5OTI1IEM2Ljk5OTI1LDYuMjIzMjUgNy4yMjMyNSw1Ljk5OTI1IDcuNDk5MjUsNS45OTkyNSBMMTAuNDk5MjUsNS45OTkyNSBDMTAuNzc1MjUsNS45OTkyNSAxMC45OTkyNSw2LjIyMzI1IDEwLjk5OTI1LDYuNDk5MjUgQzEwLjk5OTI1LDYuNzc1MjUgMTAuNzc1MjUsNi45OTkyNSAxMC40OTkyNSw2Ljk5OTI1IFogTTEyLjQ5OTI1LDkuOTk5MjUgTDcuNDk5MjUsOS45OTkyNSBDNy4yMjMyNSw5Ljk5OTI1IDYuOTk5MjUsOS43NzUyNSA2Ljk5OTI1LDkuNDk5MjUgQzYuOTk5MjUsOS4yMjMyNSA3LjIyMzI1LDguOTk5MjUgNy40OTkyNSw4Ljk5OTI1IEwxMi40OTkyNSw4Ljk5OTI1IEMxMi43NzUyNSw4Ljk5OTI1IDEyLjk5OTI1LDkuMjIzMjUgMTIuOTk5MjUsOS40OTkyNSBDMTIuOTk5MjUsOS43NzUyNSAxMi43NzUyNSw5Ljk5OTI1IDEyLjQ5OTI1LDkuOTk5MjUgWiBNMTQuNDk5MjUsMTIuOTk5MjUgTDcuNDk5MjUsMTIuOTk5MjUgQzcuMjIzMjUsMTIuOTk5MjUgNi45OTkyNSwxMi43NzUyNSA2Ljk5OTI1LDEyLjQ5OTI1IEM2Ljk5OTI1LDEyLjIyMzI1IDcuMjIzMjUsMTEuOTk5MjUgNy40OTkyNSwxMS45OTkyNSBMMTQuNDk5MjUsMTEuOTk5MjUgQzE0Ljc3NTI1LDExLjk5OTI1IDE0Ljk5OTI1LDEyLjIyMzI1IDE0Ljk5OTI1LDEyLjQ5OTI1IEMxNC45OTkyNSwxMi43NzUyNSAxNC43NzUyNSwxMi45OTkyNSAxNC40OTkyNSwxMi45OTkyNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDAgMTYpIi8+PC9zdmc+');

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1061_7579)">
<path d="M0 2V14.2353H16V2H0ZM9.41176 3.88235V12.3529H6.58824V3.88235H9.41176ZM1.88235 3.88235H4.70588V12.3529H1.88235V3.88235ZM14.1176 12.3529H11.2941V3.88235H14.1176V12.3529Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1061_7579">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 442 B

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1061_7601)">
<path d="M14.5455 1H1.45455C0.654545 1 0 1.65455 0 2.45455V11.1818C0 11.9818 0.654545 12.6364 1.45455 12.6364H6.54545L5.09091 14.8182V15.5455H10.9091V14.8182L9.45455 12.6364H14.5455C15.3455 12.6364 16 11.9818 16 11.1818V2.45455C16 1.65455 15.3455 1 14.5455 1ZM14.5455 9.72727H1.45455V2.45455H14.5455V9.72727Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1061_7601">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 574 B

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1061_7598)">
<path d="M12 0H4C3.46957 0 2.96086 0.210714 2.58579 0.585786C2.21071 0.960859 2 1.46957 2 2V14C2 14.5304 2.21071 15.0391 2.58579 15.4142C2.96086 15.7893 3.46957 16 4 16H12C12.5304 16 13.0391 15.7893 13.4142 15.4142C13.7893 15.0391 14 14.5304 14 14V2C14 1.46957 13.7893 0.960859 13.4142 0.585786C13.0391 0.210714 12.5304 0 12 0ZM4 13V3H12V13H4Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1061_7598">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 609 B

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1061_7582)">
<path d="M5.92308 2.46154V7.38462C4.56923 7.38462 3.46154 6.27692 3.46154 4.92308C3.46154 3.56923 4.56923 2.46154 5.92308 2.46154ZM14 0H5.92308C3.20308 0 1 2.20308 1 4.92308C1 7.64308 3.20308 9.84615 5.92308 9.84615V16H7.5V2.46154H9.59615V16H11.25L11.25 2.46154H14V0Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1061_7582">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 533 B

@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1061_7593)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.10526 4.21094H13.8947C15.0574 4.21094 16 5.1535 16 6.3162V9.68462C16 10.8473 15.0574 11.7899 13.8947 11.7899H2.10526C0.942558 11.7899 0 10.8473 0 9.68462V6.3162C0 5.1535 0.942558 4.21094 2.10526 4.21094ZM2.10526 5.4741C1.64018 5.4741 1.26316 5.85112 1.26316 6.3162V9.68462C1.26316 10.1497 1.64018 10.5267 2.10526 10.5267H13.8947C14.3598 10.5267 14.7368 10.1497 14.7368 9.68462V6.3162C14.7368 5.85112 14.3598 5.4741 13.8947 5.4741H2.10526Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.26316 16.0001H0V15.158L4.30659e-05 15.1444C0.00736708 13.9879 0.9471 13.0527 2.10526 13.0527H3.57895V14.3159H2.10526C1.64018 14.3159 1.26316 14.6929 1.26316 15.158V16.0001ZM9.47368 14.3159H6.52632V13.0527H9.47368V14.3159ZM14.7368 16.0001V15.158C14.7368 14.6929 14.3598 14.3159 13.8947 14.3159H12.4211V13.0527H13.8947C15.0574 13.0527 16 13.9953 16 15.158V16.0001H14.7368Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.7368 -0.000102818H16V0.842002L16 0.855618C15.9926 2.01206 15.0529 2.94727 13.8947 2.94727H12.4211V1.68411L13.8947 1.68411C14.3598 1.68411 14.7368 1.30708 14.7368 0.842002V-0.000102818ZM6.52632 1.68411L9.47368 1.68411V2.94727L6.52632 2.94727V1.68411ZM1.26316 -0.000102818V0.842002C1.26316 1.30708 1.64018 1.68411 2.10526 1.68411L3.57895 1.68411V2.94727L2.10526 2.94727C0.942558 2.94727 -1.19209e-07 2.00471 -1.19209e-07 0.842002V-0.000102818H1.26316Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1061_7593">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1061_7588)">
<rect x="1.33337" y="7" width="13.3333" height="1.33333" fill="black"/>
<rect y="5" width="1.33333" height="5.33333" fill="black"/>
<rect x="14.6667" y="5" width="1.33333" height="5.33333" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1061_7588">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 453 B

@ -5,6 +5,7 @@ import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('FormView', function() {
this.timeout('90s');
gu.bigScreen();
let api: UserAPI;
let docId: string;
@ -62,7 +63,7 @@ describe('FormView', function() {
assert.isUndefined(await api.getTable(docId, 'Table1').then(t => t.D));
// Add a text question
await drop().click();
await plusButton().click();
if (more) {
await clickMenu('More');
}
@ -87,6 +88,9 @@ describe('FormView', function() {
await cb.paste();
});
// Select it
await question('D').click();
return await driver.find('#clipboardText').value();
}
@ -181,9 +185,6 @@ describe('FormView', function() {
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);
@ -218,7 +219,7 @@ describe('FormView', function() {
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.findWait('input[name="D"]', 1000).findClosest("label").click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -299,11 +300,7 @@ describe('FormView', function() {
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()
);
}
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// And a submit button.
assert.isTrue(await driver.findContent('.test-forms-submit', gu.exactMatch('Submit')).isDisplayed());
@ -386,7 +383,7 @@ describe('FormView', function() {
await driver.withActions(a =>
a.move({origin: questionDrag('A')})
.press()
.move({origin: drop().drag()})
.move({origin: plusButton().drag()})
.release()
);
@ -396,7 +393,7 @@ describe('FormView', function() {
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// Now add a new question.
await drop().click();
await plusButton().click();
await clickMenu('Text');
await gu.waitForServer();
@ -452,15 +449,14 @@ describe('FormView', function() {
// Now B is selected.
assert.equal(await selectedLabel(), 'B');
// Click on the dropzone.
await drop().click();
await gu.sendKeys(Key.ESCAPE);
// Click on the edit button.
await driver.find('.test-forms-submit').click();
// Now nothing is selected.
assert.isFalse(await isSelected());
assert.isFalse(await isSelected(), 'Something is selected');
// When we add new question, it is automatically selected.
await drop().click();
await plusButton().click();
await clickMenu('Text');
await gu.waitForServer();
// Now D is selected.
@ -478,6 +474,12 @@ describe('FormView', function() {
// We have only one hidden column.
assert.deepEqual(await hiddenColumns(), ['Choice']);
// Make sure we see it in the menu.
await plusButton().click();
// We have 1 unmapped menu item.
assert.equal(await elementCount('menu-unmapped'), 1);
// Now move it to the form on B
await driver.withActions(a =>
a.move({origin: hiddenColumn('Choice')})
@ -504,6 +506,7 @@ describe('FormView', function() {
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
assert.deepEqual(await hiddenColumns(), []);
// Now hide it using menu.
await question('Choice').rightClick();
await clickMenu('Hide');
@ -518,7 +521,21 @@ describe('FormView', function() {
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
assert.deepEqual(await hiddenColumns(), []);
// And redo.
await gu.redo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
assert.deepEqual(await hiddenColumns(), ['Choice']);
// Now unhide it using menu.
await plusButton().click();
await element('menu-unmapped').click();
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'Choice']);
assert.deepEqual(await hiddenColumns(), []);
// Now hide it using Delete key.
await driver.find('.test-forms-submit').click();
await question('Choice').click();
await gu.sendKeys(Key.DELETE);
await gu.waitForServer();
@ -527,6 +544,7 @@ describe('FormView', function() {
assert.deepEqual(await hiddenColumns(), ['Choice']);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
await gu.toggleSidePanel('right', 'close');
});
@ -535,30 +553,27 @@ describe('FormView', function() {
assert.equal(await selectedLabel(), 'A');
// Move down.
await gu.sendKeys(Key.ARROW_DOWN);
await arrow(Key.ARROW_DOWN);
assert.equal(await selectedLabel(), 'B');
// Move up.
await gu.sendKeys(Key.ARROW_UP);
await arrow(Key.ARROW_UP);
assert.equal(await selectedLabel(), 'A');
// Move down to C.
await gu.sendKeys(Key.ARROW_DOWN);
await gu.sendKeys(Key.ARROW_DOWN);
await arrow(Key.ARROW_DOWN, 2);
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);
// Move down we should be at A (past the submit button, and titles and sections).
await arrow(Key.ARROW_DOWN, 7);
assert.equal(await selectedLabel(), 'A');
// Do the same with Left and Right.
await gu.sendKeys(Key.ARROW_RIGHT);
await arrow(Key.ARROW_RIGHT);
assert.equal(await selectedLabel(), 'B');
await gu.sendKeys(Key.ARROW_LEFT);
await arrow(Key.ARROW_LEFT);
assert.equal(await selectedLabel(), 'A');
await gu.sendKeys(Key.ARROW_RIGHT);
await gu.sendKeys(Key.ARROW_RIGHT);
await arrow(Key.ARROW_RIGHT, 2);
assert.equal(await selectedLabel(), 'C');
});
@ -578,14 +593,13 @@ describe('FormView', function() {
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// To the same for paragraph.
await drop().click();
await plusButton().click();
await clickMenu('Paragraph');
await gu.waitForServer();
await element('Paragraph').click();
await element('Paragraph', 5).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.
@ -597,13 +611,18 @@ describe('FormView', function() {
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
let elements = await driver.findAll('.test-forms-element');
assert.isTrue(await elements[0].matches('.test-forms-Paragraph'));
assert.isTrue(await elements[1].matches('.test-forms-Paragraph'));
assert.isTrue(await elements[2].matches('.test-forms-Section'));
assert.isTrue(await elements[3].matches('.test-forms-Paragraph'));
assert.isTrue(await elements[4].matches('.test-forms-Paragraph'));
assert.isTrue(await elements[5].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'));
assert.isTrue(await elements[5].matches('.test-forms-Field'));
// 0 - A, 1 - B, 2 - C, 3 - submit button.
assert.isTrue(await elements[4].matches('.test-forms-Paragraph'));
assert.isTrue(await elements[8].matches('.test-forms-Paragraph'));
await revert();
});
@ -617,7 +636,7 @@ describe('FormView', function() {
};
const checkFieldsAtFirstLevel = (menuText: string) => {
it(`can add ${menuText} elements from the menu`, async function() {
await drop().click();
await plusButton().click();
await clickMenu(menuText);
await gu.waitForServer();
await checkNewCol();
@ -631,7 +650,7 @@ describe('FormView', function() {
const checkFieldInMore = (menuText: string) => {
it(`can add ${menuText} elements from the menu`, async function() {
await drop().click();
await plusButton().click();
await clickMenu('More');
await clickMenu(menuText);
await gu.waitForServer();
@ -645,44 +664,43 @@ describe('FormView', function() {
checkFieldInMore('Choice List');
checkFieldInMore('Reference');
checkFieldInMore('Reference List');
checkFieldInMore('Attachment');
const testStruct = (type: string) => {
const testStruct = (type: string, existing = 0) => {
it(`can add structure ${type} element`, async function() {
assert.equal(await elementCount(type), 0);
await drop().click();
assert.equal(await elementCount(type), existing);
await plusButton().click();
await clickMenu(type);
await gu.waitForServer();
assert.equal(await elementCount(type), 1);
assert.equal(await elementCount(type), existing + 1);
await gu.undo();
assert.equal(await elementCount(type), 0);
assert.equal(await elementCount(type), existing);
});
};
testStruct('Section');
// testStruct('Section'); // There is already a section
testStruct('Columns');
testStruct('Paragraph');
testStruct('Paragraph', 4);
it('basic section', async function() {
const revert = await gu.begin();
// Add structure.
await drop().click();
await clickMenu('Section');
await gu.waitForServer();
// Adding section is disabled for now, so this test is altered to use the existing section.
// 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 element('Section', 1).element('plus').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');
assert.equal(await element('Section', 1).element('label', 4).value(), 'D');
// Make sure we can move that question around.
await driver.withActions(a =>
@ -696,11 +714,11 @@ describe('FormView', function() {
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);
// assert.equal(await element('Section', 1).element('label').isPresent(), false);
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await element('Section').element('label').getText(), 'D');
assert.equal(await element('Section', 1).element('label', 4).value(), 'D');
await revert();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
@ -708,7 +726,7 @@ describe('FormView', function() {
it('basic columns work', async function() {
const revert = await gu.begin();
await drop().click();
await plusButton().click();
await clickMenu('Columns');
await gu.waitForServer();
@ -723,7 +741,7 @@ describe('FormView', function() {
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 element('Columns').element(`Placeholder`, 2).click();
await clickMenu('Text');
await gu.waitForServer();
@ -733,7 +751,7 @@ describe('FormView', function() {
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
// The question D is in the columns.
assert.equal(await element('Columns').element('label').getText(), 'D');
assert.equal(await element('Columns').element('label').value(), 'D');
// We can move it around.
await driver.withActions(a =>
@ -749,7 +767,7 @@ describe('FormView', function() {
await driver.withActions(a =>
a.move({origin: questionDrag('D')})
.press()
.move({origin: element('Columns').find(`.test-forms-editor:nth-child(2) .test-forms-drag`)})
.move({origin: element('Columns').element(`Placeholder`, 2).find(`.test-forms-drag`)})
.release()
);
await gu.waitForServer();
@ -760,7 +778,7 @@ describe('FormView', function() {
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.equal(await allColumns[1].find('.test-forms-label').value(), 'D');
assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder'));
// Check that we can remove the question.
@ -780,7 +798,7 @@ describe('FormView', function() {
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.equal(await allColumns[1].find('.test-forms-label').value(), 'D');
assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder'));
await revert();
@ -790,7 +808,7 @@ describe('FormView', function() {
it('changes type of a question', async function() {
// Add text question as D column.
await drop().click();
await plusButton().click();
await clickMenu('Text');
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
@ -826,8 +844,21 @@ describe('FormView', function() {
});
});
function element(type: string, parent?: WebElement) {
return extra((parent ?? driver).find(`.test-forms-${type}`));
function element(type: string, parent?: WebElement): ExtraElement;
function element(type: string, index: number, parent?: WebElement): ExtraElement;
function element(type: string, arg1?: number | WebElement, arg2?: WebElement): ExtraElement {
if (typeof arg1 === 'number') {
if (arg1 === 1) {
return extra((arg2 ?? driver).find(`.test-forms-${type}`));
}
const nth = ((arg2 ?? driver).findAll(`.test-forms-${type}`).then(els => els[arg1 - 1])).then(el => {
if (!el) { throw new Error(`No element of type ${type} at index ${arg1}`); }
return el;
});
return extra(new WebElementPromise(driver, nth));
} else {
return extra((arg1 ?? driver).find(`.test-forms-${type}`));
}
}
async function elementCount(type: string, parent?: WebElement) {
@ -835,12 +866,11 @@ async function elementCount(type: string, parent?: WebElement) {
}
async function readLabels() {
return await driver.findAll('.test-forms-question .test-forms-label', el => el.getText());
return await driver.findAll('.test-forms-question .test-forms-label', el => el.value());
}
function question(label: string) {
return extra(driver.findContent('.test-forms-question .test-forms-label', gu.exactMatch(label))
.findClosest('.test-forms-editor'));
return extra(gu.findValue(`.test-forms-label`, label).findClosest('.test-forms-editor'));
}
function questionDrag(label: string) {
@ -851,12 +881,12 @@ function questionType(label: string) {
return question(label).find('.test-forms-type').value();
}
function drop() {
return element('dropzone');
function plusButton() {
return element('plus');
}
function drops() {
return driver.findAll('.test-forms-dropzone');
return driver.findAll('.test-forms-plus');
}
async function clickMenu(label: string) {
@ -876,7 +906,7 @@ function selected() {
}
function selectedLabel() {
return selected().find('.test-forms-label').getText();
return selected().find('.test-forms-label').value();
}
function hiddenColumns() {
@ -889,7 +919,7 @@ function hiddenColumn(label: string) {
type ExtraElement = WebElementPromise & {
rightClick: () => Promise<void>,
element: (type: string) => ExtraElement,
element: (type: string, index?: number) => ExtraElement,
/**
* A draggable element inside. This is 2x2px div to help with drag and drop.
*/
@ -903,8 +933,8 @@ function extra(el: WebElementPromise): ExtraElement {
await driver.withActions(a => a.contextClick(webElement));
};
webElement.element = function(type: string) {
return element(type, webElement);
webElement.element = function(type: string, index?: number) {
return element(type, index ?? 1, webElement);
};
webElement.drag = function() {
@ -913,3 +943,9 @@ function extra(el: WebElementPromise): ExtraElement {
return webElement;
}
async function arrow(key: string, times: number = 1) {
for (let i = 0; i < times; i++) {
await gu.sendKeys(key);
}
}

@ -3562,7 +3562,7 @@ export const choicesEditor = {
return (await driver.find(".test-choice-list-entry-edit").getText()) === "Reset";
},
async reset() {
await driver.find(".test-choice-list-entry-edit").click();
await driver.findWait(".test-choice-list-entry-edit", 100).click();
},
async label() {
return await driver.find(".test-choice-list-entry-row").getText();
@ -3599,6 +3599,20 @@ export const choicesEditor = {
}
};
export function findValue(selector: string, value: string|RegExp) {
const inner = async () => {
const all = await driver.findAll(selector);
const tested: string[] = [];
for(const el of all) {
const elValue = await el.value();
tested.push(elValue);
const found = typeof value === 'string' ? elValue === value : value.test(elValue);
if (found) { return el; }
}
throw new Error(`No element found matching ${selector}, tested ${tested.join(', ')}`);
};
return new WebElementPromise(driver, inner());
}
} // end of namespace gristUtils

Loading…
Cancel
Save