(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
@ -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) {
|
||||
|
209
app/client/components/Forms/Editor.ts
Normal file
@ -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')) || [];
|
||||
});
|
||||
return style.cssSelect(
|
||||
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 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,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`);
|
85
app/client/components/Forms/Label.ts
Normal file
@ -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;
|
||||
}
|
||||
}})
|
||||
);
|
||||
}
|
||||
}
|
170
app/client/components/Forms/Menu.ts
Normal file
@ -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')),
|
||||
]),
|
||||
);
|
||||
};
|
274
app/client/components/Forms/UnmappedFieldsConfig.ts
Normal file
@ -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);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssLabel = styled('label', `
|
||||
export const cssSectionEditor = styled('div', `
|
||||
border-radius: 3px;
|
||||
padding: 16px;
|
||||
border: 1px solid ${theme.modalBorderDark};
|
||||
`);
|
||||
|
||||
|
||||
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;
|
||||
`);
|
||||
|
||||
const cssDesc = styled('div', `
|
||||
font-size: 10px;
|
||||
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;
|
||||
`);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
class Text implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
abstract class BaseQuestion implements Question {
|
||||
public async toHTML(field: FieldModel, context: RenderContext): Promise<string> {
|
||||
return `
|
||||
<input type='text' name='${field.colId}' />
|
||||
<div class='grist-question'>
|
||||
${this.label(field)}
|
||||
<div class='grist-field-content'>
|
||||
${await this.input(field, context)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public label(field: FieldModel): string {
|
||||
// This might be HTML.
|
||||
const label = field.question;
|
||||
const name = field.colId;
|
||||
return `
|
||||
<label class='grist-label' for='${name}'>${label}</label>
|
||||
`;
|
||||
}
|
||||
|
||||
public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>;
|
||||
}
|
||||
|
||||
class Text extends BaseQuestion {
|
||||
public input(field: FieldModel, context: RenderContext): string {
|
||||
const required = field.options.formRequired ? 'required' : '';
|
||||
return `
|
||||
<input type='text' name='${field.colId}' ${required}/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Date 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='date' name='${field.colId}' />
|
||||
<input type='date' name='${field.colId}' ${required}/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class DateTime 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}' />
|
||||
<input type='datetime-local' name='${field.colId}' ${required}/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Choice implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
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 `
|
||||
<label>
|
||||
<input type='checkbox' name='${field.colId}' value="1" />
|
||||
Yes
|
||||
<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 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
|
||||
|
434
static/forms/form.css
Normal file
@ -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_")) {
|
||||
|
BIN
static/forms/logo.png
Normal file
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+');
|
||||
|
10
static/ui-icons/UI/Columns.svg
Normal file
@ -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 |
10
static/ui-icons/UI/Desktop.svg
Normal file
@ -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 |
10
static/ui-icons/UI/Mobile.svg
Normal file
@ -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 |
10
static/ui-icons/UI/Paragraph.svg
Normal file
@ -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 |
12
static/ui-icons/UI/Section.svg
Normal file
@ -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 |
12
static/ui-icons/UI/Separator.svg
Normal file
@ -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
|
||||
|
||||
|