(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
This commit is contained in:
Jarosław Sadziński
2024-01-18 18:23:50 +01:00
parent b82209b458
commit 0aad09a4ed
55 changed files with 3468 additions and 1410 deletions

View File

@@ -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) {

View 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,
);
}

View File

@@ -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};
`);

View File

@@ -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';

View File

@@ -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;
`);

View 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;
}
}})
);
}
}

View 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,
];
}

View File

@@ -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();

View File

@@ -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};
}

View File

@@ -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', `
`);

View File

@@ -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"))
);
}
}

View File

@@ -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')),
]),
);
};

View 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;
}
`);

View File

@@ -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};
}
}

View File

@@ -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);
}
}),
];
}

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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'),
)
);
});
};

View File

@@ -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);

View File

@@ -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'
}, {

View File

@@ -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);
}
};
}

View File

@@ -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;

View File

@@ -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({

View File

@@ -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};

View File

@@ -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"),

View File

@@ -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'}),
),
)

View File

@@ -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)

View File

@@ -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)),
);
}

View File

@@ -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",

View File

@@ -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>;

View File

@@ -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)], {});
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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

View File

@@ -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()
])
];
}

View File

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

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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',
}

View File

@@ -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>;

View File

@@ -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);
}