mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -1,10 +1,12 @@
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import {Box, BoxModel} from 'app/client/components/Forms/Model';
|
||||
import * as style from 'app/client/components/Forms/styles';
|
||||
import {Box, BoxModel, RenderContext} from 'app/client/components/Forms/Model';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import {inlineStyle, not} from 'app/common/gutil';
|
||||
import {bundleChanges, Computed, dom, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
@@ -12,7 +14,7 @@ export class ColumnsModel extends BoxModel {
|
||||
private _columnCount = Computed.create(this, use => use(this.children).length);
|
||||
|
||||
public removeChild(box: BoxModel) {
|
||||
if (box.type.get() === 'Placeholder') {
|
||||
if (box.type === 'Placeholder') {
|
||||
// Make sure we have at least one rendered.
|
||||
if (this.children.get().length <= 1) {
|
||||
return;
|
||||
@@ -24,33 +26,29 @@ export class ColumnsModel extends BoxModel {
|
||||
}
|
||||
|
||||
// Dropping a box on a column will replace it.
|
||||
public drop(dropped: Box): BoxModel {
|
||||
public accept(dropped: Box): BoxModel {
|
||||
if (!this.parent) { throw new Error('No parent'); }
|
||||
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
const droppedRef = this.root().find(droppedId);
|
||||
const droppedRef = this.root().get(droppedId);
|
||||
|
||||
// Now we simply insert it after this box.
|
||||
droppedRef?.removeSelf();
|
||||
|
||||
return this.parent.replace(this, dropped);
|
||||
}
|
||||
public render(context: RenderContext): HTMLElement {
|
||||
context.overlay.set(false);
|
||||
|
||||
public render(...args: IDomArgs<HTMLElement>): HTMLElement {
|
||||
// Now render the dom.
|
||||
const renderedDom = style.cssColumns(
|
||||
const content: HTMLElement = style.cssColumns(
|
||||
// Pass column count as a css variable (to style the grid).
|
||||
inlineStyle(`--css-columns-count`, this._columnCount),
|
||||
|
||||
// Render placeholders as children.
|
||||
dom.forEach(this.children, (child) => {
|
||||
return this.view.renderBox(
|
||||
this.children,
|
||||
child || BoxModel.new(Placeholder(), this),
|
||||
testId('column')
|
||||
);
|
||||
const toRender = child ?? BoxModel.new(Placeholder(), this);
|
||||
return toRender.render(testId('column'));
|
||||
}),
|
||||
|
||||
// Append + button at the end.
|
||||
@@ -60,17 +58,17 @@ export class ColumnsModel extends BoxModel {
|
||||
dom.on('click', () => this.placeAfterListChild()(Placeholder())),
|
||||
style.cssColumn.cls('-add-button')
|
||||
),
|
||||
|
||||
...args,
|
||||
);
|
||||
return renderedDom;
|
||||
return buildEditor({ box: this, content });
|
||||
}
|
||||
}
|
||||
|
||||
export class PlaceholderModel extends BoxModel {
|
||||
|
||||
public render(context: RenderContext): HTMLElement {
|
||||
const [box, view, overlay] = [this, this.view, context.overlay];
|
||||
public render(...args: IDomArgs<HTMLElement>): HTMLElement {
|
||||
const [box, view] = [this, this.view];
|
||||
const scope = new MultiHolder();
|
||||
overlay.set(false);
|
||||
|
||||
const liveIndex = Computed.create(scope, (use) => {
|
||||
if (!box.parent) { return -1; }
|
||||
@@ -91,15 +89,17 @@ export class PlaceholderModel extends BoxModel {
|
||||
const dragHover = Observable.create(scope, false);
|
||||
|
||||
return cssPlaceholder(
|
||||
style.cssDrag(),
|
||||
testId('placeholder'),
|
||||
style.cssDrop(),
|
||||
testId('Placeholder'),
|
||||
dom.autoDispose(scope),
|
||||
|
||||
style.cssColumn.cls('-drag-over', dragHover),
|
||||
style.cssColumn.cls('-empty', not(boxModelAt)),
|
||||
style.cssColumn.cls('-selected', use => use(view.selectedBox) === box),
|
||||
|
||||
view.buildAddMenu(insertBox, {
|
||||
buildMenu({
|
||||
box: this,
|
||||
insertBox,
|
||||
customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')],
|
||||
}),
|
||||
|
||||
@@ -133,7 +133,7 @@ export class PlaceholderModel extends BoxModel {
|
||||
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
const droppedRef = box.root().find(droppedId);
|
||||
const droppedRef = box.root().get(droppedId);
|
||||
if (!droppedRef) { return; }
|
||||
|
||||
// Now we simply insert it after this box.
|
||||
@@ -144,11 +144,13 @@ export class PlaceholderModel extends BoxModel {
|
||||
parent.save().catch(reportError);
|
||||
});
|
||||
}),
|
||||
|
||||
dom.maybeOwned(boxModelAt, (mscope, child) => view.renderBox(mscope, child)),
|
||||
dom.maybe(use => !use(boxModelAt) && use(view.isEdit), () => {
|
||||
return dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1)));
|
||||
}),
|
||||
// If we an occupant, render it.
|
||||
dom.maybe(boxModelAt, (child) => child.render()),
|
||||
// If not, render a placeholder.
|
||||
dom.maybe(not(boxModelAt), () =>
|
||||
dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1)))
|
||||
),
|
||||
...args,
|
||||
);
|
||||
|
||||
function insertBox(childBox: Box) {
|
||||
|
||||
209
app/client/components/Forms/Editor.ts
Normal file
209
app/client/components/Forms/Editor.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {BoxModel, parseBox} from 'app/client/components/Forms/Model';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import * as style from 'app/client/components/Forms/styles';
|
||||
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
const t = makeT('FormView.Editor');
|
||||
|
||||
interface Props {
|
||||
box: BoxModel,
|
||||
/** Should we show an overlay */
|
||||
overlay?: Observable<boolean>,
|
||||
/** Custom drag indicator slot */
|
||||
drag?: HTMLElement,
|
||||
/**
|
||||
* Actual element to put into the editor. This is the main content of the editor.
|
||||
*/
|
||||
content: DomContents,
|
||||
/**
|
||||
* Click handler. If not provided, then clicking on the editor will select it.
|
||||
*/
|
||||
click?: (ev: MouseEvent, box: BoxModel) => void,
|
||||
/**
|
||||
* Custom remove icon. If null, then no drop icon is shown.
|
||||
*/
|
||||
removeIcon?: IconName|null,
|
||||
/**
|
||||
* Custom remove button rendered atop overlay.
|
||||
*/
|
||||
removeButton?: DomContents,
|
||||
/**
|
||||
* Tooltip for the remove button.
|
||||
*/
|
||||
removeTooltip?: string,
|
||||
/**
|
||||
* Position of the remove button. Defaults to inside.
|
||||
*/
|
||||
removePosition?: 'inside'|'right',
|
||||
editMode?: Observable<boolean>,
|
||||
}
|
||||
|
||||
export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
||||
const owner: MultiHolder = new MultiHolder();
|
||||
const {box, overlay} = props;
|
||||
const view = box.view;
|
||||
const dragHover = Observable.create(owner, false);
|
||||
let element: HTMLElement;
|
||||
|
||||
// When element is selected, scroll it into view.
|
||||
owner.autoDispose(view.selectedBox.addListener(selectedBox => {
|
||||
if (selectedBox === box) {
|
||||
element?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
|
||||
}
|
||||
}));
|
||||
|
||||
// Default remove icon, can be overriden by props.
|
||||
const defaultRemoveButton = () => style.cssRemoveButton(
|
||||
icon((props.removeIcon as any) ?? 'RemoveBig'),
|
||||
dom.on('click', ev => {
|
||||
stopEvent(ev);
|
||||
box.view.selectedBox.set(box);
|
||||
allCommands.deleteFields.run();
|
||||
}),
|
||||
props.removeButton === null ? null : hoverTooltip(props.removeTooltip ?? t('Delete')),
|
||||
style.cssRemoveButton.cls('-right', props.removePosition === 'right'),
|
||||
);
|
||||
|
||||
const onClick = (ev: MouseEvent) => {
|
||||
// Only if the click was in this element.
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest) { return; }
|
||||
// Make sure that the closest editor is this one.
|
||||
const closest = target.closest(`.${style.cssFieldEditor.className}`);
|
||||
if (closest !== element) { return; }
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
props.click?.(ev, props.box);
|
||||
|
||||
// Mark this box as selected.
|
||||
box.view.selectedBox.set(box);
|
||||
};
|
||||
|
||||
const dragAbove = Observable.create(owner, false);
|
||||
const dragBelow = Observable.create(owner, false);
|
||||
const dragging = Observable.create(owner, false);
|
||||
|
||||
|
||||
return element = style.cssFieldEditor(
|
||||
testId('editor'),
|
||||
|
||||
style.cssFieldEditor.cls('-drag-above', use => use(dragAbove) && use(dragHover)),
|
||||
style.cssFieldEditor.cls('-drag-below', use => use(dragBelow) && use(dragHover)),
|
||||
|
||||
props.drag ?? style.cssDragWrapper(style.cssDrag('DragDrop')),
|
||||
style.cssFieldEditor.cls(`-${props.box.type}`),
|
||||
|
||||
// Turn on active like state when we clicked here.
|
||||
style.cssFieldEditor.cls('-selected', box.selected),
|
||||
style.cssFieldEditor.cls('-cut', box.cut),
|
||||
testId('field-editor-selected', box.selected),
|
||||
|
||||
// Select on click.
|
||||
dom.on('click', onClick),
|
||||
|
||||
// Attach context menu.
|
||||
buildMenu({
|
||||
box,
|
||||
context: true,
|
||||
}),
|
||||
|
||||
// And now drag and drop support.
|
||||
{draggable: "true"},
|
||||
|
||||
// When started, we just put the box into the dataTransfer as a plain text.
|
||||
// TODO: this might be very sofisticated in the future.
|
||||
dom.on('dragstart', (ev) => {
|
||||
// Prevent propagation, as we might be in a nested editor.
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON()));
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
dragging.set(true);
|
||||
}),
|
||||
|
||||
dom.on('dragover', (ev) => {
|
||||
// As usual, prevent propagation.
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
// Here we just change the style of the element.
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
dragHover.set(true);
|
||||
|
||||
if (dragging.get() || props.box.type === 'Section') { return; }
|
||||
|
||||
const myHeight = element.offsetHeight;
|
||||
const percentHeight = Math.round((ev.offsetY / myHeight) * 100);
|
||||
|
||||
// If we are in the top half, we want to animate ourselves and transform a little below.
|
||||
if (percentHeight < 40) {
|
||||
dragAbove.set(true);
|
||||
dragBelow.set(false);
|
||||
} else if (percentHeight > 60) {
|
||||
dragAbove.set(false);
|
||||
dragBelow.set(true);
|
||||
} else {
|
||||
dragAbove.set(false);
|
||||
dragBelow.set(false);
|
||||
}
|
||||
}),
|
||||
|
||||
dom.on('dragleave', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
// Just remove the style and stop propagation.
|
||||
dragHover.set(false);
|
||||
dragAbove.set(false);
|
||||
dragBelow.set(false);
|
||||
}),
|
||||
|
||||
dom.on('dragend', () => {
|
||||
dragHover.set(false);
|
||||
dragAbove.set(false);
|
||||
dragBelow.set(false);
|
||||
dragging.set(false);
|
||||
}),
|
||||
|
||||
dom.on('drop', async (ev) => {
|
||||
stopEvent(ev);
|
||||
dragHover.set(false);
|
||||
dragging.set(false);
|
||||
dragAbove.set(false);
|
||||
const wasBelow = dragBelow.get();
|
||||
dragBelow.set(false);
|
||||
|
||||
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
if (droppedId === box.id) { return; }
|
||||
const droppedModel = box.root().get(droppedId);
|
||||
// It might happen that parent is dropped into child, so we need to check for that.
|
||||
if (droppedModel?.get(box.id)) { return; }
|
||||
await box.save(async () => {
|
||||
droppedModel?.removeSelf();
|
||||
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
|
||||
});
|
||||
}),
|
||||
|
||||
style.cssFieldEditor.cls('-drag-hover', dragHover),
|
||||
style.cssFieldEditorContent(
|
||||
props.content,
|
||||
style.cssDrop(),
|
||||
),
|
||||
testId(box.type),
|
||||
testId('element'),
|
||||
dom.maybe(overlay, () => style.cssSelectedOverlay()),
|
||||
// Custom icons for removing.
|
||||
props.removeIcon === null || props.removeButton ? null :
|
||||
dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
|
||||
props.removeButton ?? null,
|
||||
...args,
|
||||
);
|
||||
}
|
||||
@@ -1,133 +1,297 @@
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {Box, BoxModel, ignoreClick, RenderContext} from 'app/client/components/Forms/Model';
|
||||
import * as style from 'app/client/components/Forms/styles';
|
||||
import {ViewFieldRec} from 'app/client/models/DocModel';
|
||||
import {Box, BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
|
||||
import * as css from 'app/client/components/Forms/styles';
|
||||
import {stopEvent} from 'app/client/lib/domUtils';
|
||||
import {refRecord} from 'app/client/models/DocModel';
|
||||
import {autoGrow} from 'app/client/ui/forms';
|
||||
import {squareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {Constructor} from 'app/common/gutil';
|
||||
import {BindableValue, Computed, Disposable, dom, DomContents,
|
||||
IDomComponent, makeTestId, Observable, toKo} from 'grainjs';
|
||||
import {
|
||||
BindableValue,
|
||||
Computed,
|
||||
Disposable,
|
||||
dom,
|
||||
DomContents,
|
||||
DomElementArg,
|
||||
IDomArgs,
|
||||
makeTestId,
|
||||
MultiHolder,
|
||||
observable,
|
||||
Observable,
|
||||
styled,
|
||||
toKo
|
||||
} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
/**
|
||||
* Base class for all field models.
|
||||
* Container class for all fields.
|
||||
*/
|
||||
export class FieldModel extends BoxModel {
|
||||
|
||||
/**
|
||||
* Edit mode, (only one element can be in edit mode in the form editor).
|
||||
*/
|
||||
public edit = Observable.create(this, false);
|
||||
public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)()));
|
||||
public field = this.view.gristDoc.docModel.viewFields.createFloatingRowModel(this.fieldRef);
|
||||
|
||||
public field = refRecord(this.view.gristDoc.docModel.viewFields, this.fieldRef);
|
||||
public colId = Computed.create(this, (use) => use(use(this.field).colId));
|
||||
public column = Computed.create(this, (use) => use(use(this.field).column));
|
||||
public question = Computed.create(this, (use) => {
|
||||
return use(this.field.question) || use(this.field.origLabel);
|
||||
const field = use(this.field);
|
||||
if (field.isDisposed() || use(field.id) === 0) { return ''; }
|
||||
return use(field.question) || use(field.origLabel);
|
||||
});
|
||||
|
||||
public description = Computed.create(this, (use) => {
|
||||
return use(this.field.description);
|
||||
const field = use(this.field);
|
||||
return use(field.description);
|
||||
});
|
||||
|
||||
/**
|
||||
* Column type of the field.
|
||||
*/
|
||||
public colType = Computed.create(this, (use) => {
|
||||
return use(use(this.field.column).pureType);
|
||||
const field = use(this.field);
|
||||
return use(use(field.column).pureType);
|
||||
});
|
||||
|
||||
/**
|
||||
* Field row id.
|
||||
*/
|
||||
public get leaf() {
|
||||
return this.props['leaf'] as Observable<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A renderer of question instance.
|
||||
*/
|
||||
public renderer = Computed.create(this, (use) => {
|
||||
const ctor = fieldConstructor(use(this.colType));
|
||||
const instance = new ctor(this.field);
|
||||
const instance = new ctor(this);
|
||||
use.owner.autoDispose(instance);
|
||||
return instance;
|
||||
});
|
||||
|
||||
constructor(box: Box, parent: BoxModel | null, view: FormView) {
|
||||
super(box, parent, view);
|
||||
|
||||
this.question.onWrite(value => {
|
||||
this.field.peek().question.setAndSave(value).catch(reportError);
|
||||
});
|
||||
|
||||
this.autoDispose(
|
||||
this.selected.addListener((now, then) => {
|
||||
if (!now && then) {
|
||||
setImmediate(() => !this.edit.isDisposed() && this.edit.set(false));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async onDrop() {
|
||||
await super.onDrop();
|
||||
public async afterDrop() {
|
||||
// Base class does good job of handling drop.
|
||||
await super.afterDrop();
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
// Except when a field is dragged from the creator panel, which stores colId instead of fieldRef (as there is no
|
||||
// field yet). In this case, we need to create a field.
|
||||
if (typeof this.leaf.get() === 'string') {
|
||||
this.leaf.set(await this.view.showColumn(this.leaf.get()));
|
||||
}
|
||||
}
|
||||
public render(context: RenderContext) {
|
||||
const model = this;
|
||||
|
||||
return dom('div',
|
||||
testId('question'),
|
||||
style.cssLabel(
|
||||
testId('label'),
|
||||
dom.text(model.question)
|
||||
),
|
||||
testType(this.colType),
|
||||
dom.domComputed(this.renderer, (renderer) => renderer.buildDom()),
|
||||
dom.maybe(model.description, (description) => [
|
||||
style.cssDesc(description, testId('description')),
|
||||
]),
|
||||
public override render(...args: IDomArgs<HTMLElement>): HTMLElement {
|
||||
// Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
|
||||
const save = (value: string) => {
|
||||
value = value?.trim();
|
||||
// If question is empty or same as original, don't save.
|
||||
if (!value || value === this.field.peek().question()) {
|
||||
return;
|
||||
}
|
||||
this.field.peek().question.setAndSave(value).catch(reportError);
|
||||
};
|
||||
const overlay = Observable.create(null, true);
|
||||
|
||||
const content = dom.domComputed(this.renderer, (r) => r.buildDom({
|
||||
edit: this.edit,
|
||||
overlay,
|
||||
onSave: save,
|
||||
}, ...args));
|
||||
|
||||
return buildEditor({
|
||||
box: this,
|
||||
overlay,
|
||||
removeIcon: 'CrossBig',
|
||||
removeTooltip: 'Hide',
|
||||
editMode: this.edit,
|
||||
content,
|
||||
},
|
||||
dom.on('dblclick', () => this.selected.get() && this.edit.set(true)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public async deleteSelf() {
|
||||
const rowId = this.field.getRowId();
|
||||
const rowId = this.field.peek().id.peek();
|
||||
const view = this.view;
|
||||
const root = this.root();
|
||||
this.removeSelf();
|
||||
// The order here matters for undo.
|
||||
await this.save();
|
||||
// We are disposed at this point, be still can access the view.
|
||||
if (rowId) {
|
||||
await view.viewSection.removeField(rowId);
|
||||
}
|
||||
await root.save(async () => {
|
||||
// Make sure to save first layout without this field, otherwise the undo won't work properly.
|
||||
await root.save();
|
||||
// We are disposed at this point, be still can access the view.
|
||||
if (rowId) {
|
||||
await view.viewSection.removeField(rowId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Question extends Disposable implements IDomComponent {
|
||||
constructor(public field: ViewFieldRec) {
|
||||
export abstract class Question extends Disposable {
|
||||
constructor(public model: FieldModel) {
|
||||
super();
|
||||
}
|
||||
|
||||
public abstract buildDom(): DomContents;
|
||||
public buildDom(props: {
|
||||
edit: Observable<boolean>,
|
||||
overlay: Observable<boolean>,
|
||||
onSave: (value: string) => void,
|
||||
}, ...args: IDomArgs<HTMLElement>) {
|
||||
return css.cssPadding(
|
||||
testId('question'),
|
||||
testType(this.model.colType),
|
||||
this.renderLabel(props, dom.style('margin-bottom', '5px')),
|
||||
this.renderInput(),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
public abstract renderInput(): DomContents;
|
||||
|
||||
protected renderLabel(props: {
|
||||
edit: Observable<boolean>,
|
||||
onSave: (value: string) => void,
|
||||
}, ...args: DomElementArg[]) {
|
||||
const {edit, onSave} = props;
|
||||
|
||||
const scope = new MultiHolder();
|
||||
|
||||
// When in edit, we will update a copy of the question.
|
||||
const draft = Observable.create(scope, this.model.question.get());
|
||||
scope.autoDispose(
|
||||
this.model.question.addListener(q => draft.set(q)),
|
||||
);
|
||||
const controller = Computed.create(scope, (use) => use(draft));
|
||||
controller.onWrite(value => {
|
||||
if (this.isDisposed() || draft.isDisposed()) { return; }
|
||||
if (!edit.get()) { return; }
|
||||
draft.set(value);
|
||||
});
|
||||
|
||||
// Wire up save method.
|
||||
const saveDraft = (ok: boolean) => {
|
||||
if (this.isDisposed() || draft.isDisposed()) { return; }
|
||||
if (!ok || !edit.get() || !controller.get()) {
|
||||
controller.set(this.model.question.get());
|
||||
return;
|
||||
}
|
||||
onSave(controller.get());
|
||||
};
|
||||
let element: HTMLTextAreaElement;
|
||||
|
||||
scope.autoDispose(
|
||||
props.edit.addListener((now, then) => {
|
||||
if (now && !then) {
|
||||
// When we go into edit mode, we copy the question into draft.
|
||||
draft.set(this.model.question.get());
|
||||
// And focus on the element.
|
||||
setTimeout(() => {
|
||||
element?.focus();
|
||||
element?.select();
|
||||
}, 10);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return [
|
||||
dom.autoDispose(scope),
|
||||
element = css.cssEditableLabel(
|
||||
controller,
|
||||
{onInput: true},
|
||||
// Attach common Enter,Escape, blur handlers.
|
||||
css.saveControls(edit, saveDraft),
|
||||
// Autoselect whole text when mounted.
|
||||
// Auto grow for textarea.
|
||||
autoGrow(controller),
|
||||
// Enable normal menu.
|
||||
dom.on('contextmenu', stopEvent),
|
||||
dom.style('resize', 'none'),
|
||||
testId('label'),
|
||||
css.cssEditableLabel.cls('-edit', props.edit),
|
||||
// When selected, we want to be able to edit the label by clicking it
|
||||
// so we need to make it relative and z-indexed.
|
||||
dom.style('position', u => u(this.model.selected) ? 'relative' : 'static'),
|
||||
dom.style('z-index', '2'),
|
||||
dom.on('click', (ev) => {
|
||||
if (this.model.selected.get() && !props.edit.get()) {
|
||||
props.edit.set(true);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}),
|
||||
...args,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TextModel extends Question {
|
||||
public buildDom() {
|
||||
return style.cssInput(
|
||||
dom.prop('name', this.field.colId),
|
||||
public renderInput() {
|
||||
return css.cssInput(
|
||||
dom.prop('name', u => u(u(this.model.field).colId)),
|
||||
{disabled: true},
|
||||
{type: 'text', tabIndex: "-1"},
|
||||
ignoreClick
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceModel extends Question {
|
||||
public buildDom() {
|
||||
const field = this.field;
|
||||
public renderInput() {
|
||||
const field = this.model.field;
|
||||
const choices: Computed<string[]> = Computed.create(this, use => {
|
||||
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
|
||||
return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || [];
|
||||
});
|
||||
return style.cssSelect(
|
||||
const typedChoices = Computed.create(this, use => {
|
||||
const value = use(choices);
|
||||
// Make sure it is array of strings.
|
||||
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
||||
return [];
|
||||
}
|
||||
return value;
|
||||
});
|
||||
return css.cssSelect(
|
||||
{tabIndex: "-1"},
|
||||
ignoreClick,
|
||||
dom.prop('name', this.field.colId),
|
||||
dom.forEach(choices, (choice) => dom('option', choice, {value: choice})),
|
||||
dom.prop('name', use => use(use(field).colId)),
|
||||
dom.forEach(typedChoices, (choice) => dom('option', choice, {value: choice})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceListModel extends Question {
|
||||
public buildDom() {
|
||||
const field = this.field;
|
||||
public renderInput() {
|
||||
const field = this.model.field;
|
||||
const choices: Computed<string[]> = Computed.create(this, use => {
|
||||
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
|
||||
return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || [];
|
||||
});
|
||||
return dom('div',
|
||||
dom.prop('name', this.field.colId),
|
||||
dom.forEach(choices, (choice) => style.cssLabel(
|
||||
dom('input',
|
||||
dom.prop('name', this.field.colId),
|
||||
{type: 'checkbox', value: choice, style: 'margin-right: 5px;'}
|
||||
),
|
||||
dom.prop('name', use => use(use(field).colId)),
|
||||
dom.forEach(choices, (choice) => css.cssCheckboxLabel(
|
||||
squareCheckbox(observable(false)),
|
||||
choice
|
||||
)),
|
||||
dom.maybe(use => use(choices).length === 0, () => [
|
||||
@@ -138,25 +302,38 @@ class ChoiceListModel extends Question {
|
||||
}
|
||||
|
||||
class BoolModel extends Question {
|
||||
public buildDom() {
|
||||
return dom('div',
|
||||
style.cssLabel(
|
||||
{style: 'display: flex; align-items: center; gap: 8px;'},
|
||||
dom('input',
|
||||
dom.prop('name', this.field.colId),
|
||||
{type: 'checkbox', name: 'choice', style: 'margin: 0px; padding: 0px;'}
|
||||
),
|
||||
'Yes'
|
||||
public override buildDom(props: {
|
||||
edit: Observable<boolean>,
|
||||
overlay: Observable<boolean>,
|
||||
question: Observable<string>,
|
||||
onSave: () => void,
|
||||
}) {
|
||||
return css.cssPadding(
|
||||
testId('question'),
|
||||
testType(this.model.colType),
|
||||
cssToggle(
|
||||
this.renderInput(),
|
||||
this.renderLabel(props, css.cssEditableLabel.cls('-normal')),
|
||||
),
|
||||
);
|
||||
}
|
||||
public override renderInput() {
|
||||
const value = Observable.create(this, true);
|
||||
return dom('div.widget_switch',
|
||||
dom.style('--grist-actual-cell-color', colors.lightGreen.toString()),
|
||||
dom.cls('switch_on', value),
|
||||
dom.cls('switch_transition', true),
|
||||
dom('div.switch_slider'),
|
||||
dom('div.switch_circle'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DateModel extends Question {
|
||||
public buildDom() {
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
dom('input',
|
||||
dom.prop('name', this.field.colId),
|
||||
css.cssInput(
|
||||
dom.prop('name', this.model.colId),
|
||||
{type: 'date', style: 'margin-right: 5px; width: 100%;'
|
||||
}),
|
||||
);
|
||||
@@ -164,10 +341,10 @@ class DateModel extends Question {
|
||||
}
|
||||
|
||||
class DateTimeModel extends Question {
|
||||
public buildDom() {
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
dom('input',
|
||||
dom.prop('name', this.field.colId),
|
||||
css.cssInput(
|
||||
dom.prop('name', this.model.colId),
|
||||
{type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'}
|
||||
),
|
||||
dom.style('width', '100%'),
|
||||
@@ -175,13 +352,61 @@ class DateTimeModel extends Question {
|
||||
}
|
||||
}
|
||||
|
||||
class RefListModel extends Question {
|
||||
protected choices = this._subscribeForChoices();
|
||||
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
dom.prop('name', this.model.colId),
|
||||
dom.forEach(this.choices, (choice) => css.cssLabel(
|
||||
dom('input',
|
||||
dom.prop('name', this.model.colId),
|
||||
{type: 'checkbox', value: String(choice[0]), style: 'margin-right: 5px;'}
|
||||
),
|
||||
String(choice[1] ?? '')
|
||||
)),
|
||||
dom.maybe(use => use(this.choices).length === 0, () => [
|
||||
dom('div', 'No choices defined'),
|
||||
]),
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
private _subscribeForChoices() {
|
||||
const tableId = Computed.create(this, use => {
|
||||
const refTable = use(use(this.model.column).refTable);
|
||||
return refTable ? use(refTable.tableId) : '';
|
||||
});
|
||||
|
||||
const colId = Computed.create(this, use => {
|
||||
const dispColumnIdObs = use(use(this.model.column).visibleColModel);
|
||||
return use(dispColumnIdObs.colId);
|
||||
});
|
||||
|
||||
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
|
||||
|
||||
return Computed.create(this, use => {
|
||||
const unsorted = use(observer);
|
||||
unsorted.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
return unsorted.slice(0, 50); // TODO: pagination or a waning
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RefModel extends RefListModel {
|
||||
public renderInput() {
|
||||
return css.cssSelect(
|
||||
{tabIndex: "-1"},
|
||||
ignoreClick,
|
||||
dom.prop('name', this.model.colId),
|
||||
dom.forEach(this.choices, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: decide which one we need and implement rest.
|
||||
const AnyModel = TextModel;
|
||||
const NumericModel = TextModel;
|
||||
const IntModel = TextModel;
|
||||
const RefModel = TextModel;
|
||||
const RefListModel = TextModel;
|
||||
const AttachmentsModel = TextModel;
|
||||
|
||||
|
||||
@@ -208,3 +433,10 @@ function fieldConstructor(type: string): Constructor<Question> {
|
||||
function testType(value: BindableValue<string>) {
|
||||
return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
|
||||
}
|
||||
|
||||
const cssToggle = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
--grist-actual-cell-color: ${colors.lightGreen};
|
||||
`);
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import BaseView from 'app/client/components/BaseView';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {Cursor} from 'app/client/components/Cursor';
|
||||
import * as components from 'app/client/components/Forms/elements';
|
||||
import {NewBox} from 'app/client/components/Forms/Menu';
|
||||
import {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
|
||||
import * as style from 'app/client/components/Forms/styles';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
|
||||
import {Disposable} from 'app/client/lib/dispose';
|
||||
import {AsyncComputed, makeTestId} from 'app/client/lib/domUtils';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {ShareRec} from 'app/client/models/entities/ShareRec';
|
||||
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {SortedRowSet} from 'app/client/models/rowset';
|
||||
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
|
||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssButton} from 'app/client/ui2018/buttons';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import {confirmModal} from 'app/client/ui2018/modals';
|
||||
import {not} from 'app/common/gutil';
|
||||
import {INITIAL_FIELDS_COUNT} from "app/common/Forms";
|
||||
import {Events as BackboneEvents} from 'backbone';
|
||||
import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
||||
import {Computed, dom, Holder, IDomArgs, Observable} from 'grainjs';
|
||||
import defaults from 'lodash/defaults';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const t = makeT('FormView');
|
||||
|
||||
@@ -38,8 +36,8 @@ export class FormView extends Disposable {
|
||||
public viewPane: HTMLElement;
|
||||
public gristDoc: GristDoc;
|
||||
public viewSection: ViewSectionRec;
|
||||
public isEdit: Observable<boolean>;
|
||||
public selectedBox: Observable<BoxModel | null>;
|
||||
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
|
||||
|
||||
protected sortedRows: SortedRowSet;
|
||||
protected tableModel: DataTableModel;
|
||||
@@ -60,12 +58,10 @@ export class FormView extends Disposable {
|
||||
|
||||
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
||||
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
|
||||
this.isEdit = Observable.create(this, true);
|
||||
this.menuHolder = Holder.create(this);
|
||||
|
||||
this.selectedBox = Observable.create(this, null);
|
||||
this.bundle = (clb) => this.gristDoc.docData.bundleActions('Saving form layout', clb, {nestInActiveBundle: true});
|
||||
|
||||
this.selectedBox = Observable.create(this, null);
|
||||
|
||||
this.selectedBox.addListener((v) => {
|
||||
if (!v) { return; }
|
||||
@@ -76,34 +72,49 @@ export class FormView extends Disposable {
|
||||
this.cursor.setCursorPos({fieldIndex});
|
||||
});
|
||||
|
||||
this.selectedColumns = this.autoDispose(ko.pureComputed(() => {
|
||||
const result = this.viewSection.viewFields().all().filter((field, index) => {
|
||||
// During column removal or restoring (with undo), some columns fields
|
||||
// might be disposed.
|
||||
if (field.isDisposed() || field.column().isDisposed()) { return false; }
|
||||
return this.cursor.currentPosition().fieldIndex === index;
|
||||
});
|
||||
return result;
|
||||
}));
|
||||
|
||||
// Wire up selected fields to the cursor.
|
||||
this.autoDispose(this.selectedColumns.subscribe((columns) => {
|
||||
this.viewSection.selectedFields(columns);
|
||||
}));
|
||||
this.viewSection.selectedFields(this.selectedColumns.peek());
|
||||
|
||||
|
||||
this._autoLayout = Computed.create(this, use => {
|
||||
// If the layout is already there, don't do anything.
|
||||
const existing = use(this.viewSection.layoutSpecObj);
|
||||
if (!existing || !existing.id) {
|
||||
// Else create a temporary one.
|
||||
const fields = use(use(this.viewSection.viewFields).getObservable());
|
||||
const children: Box[] = fields.map(f => {
|
||||
return {
|
||||
type: 'Field',
|
||||
leaf: use(f.id),
|
||||
};
|
||||
});
|
||||
children.push({type: 'Submit'});
|
||||
return {
|
||||
type: 'Layout',
|
||||
children,
|
||||
};
|
||||
return this._formTemplate(fields);
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
|
||||
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async () => {
|
||||
await this._saveNow();
|
||||
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
|
||||
await this.bundle(async () => {
|
||||
// If the box is autogenerated we need to save it first.
|
||||
if (!this.viewSection.layoutSpecObj.peek()?.id) {
|
||||
await this.save();
|
||||
}
|
||||
if (clb) {
|
||||
await clb();
|
||||
}
|
||||
await this.save();
|
||||
});
|
||||
}, this));
|
||||
|
||||
this._autoLayout.addListener((v) => {
|
||||
if (this._saving) {
|
||||
console.error('Layout changed while saving');
|
||||
console.warn('Layout changed while saving');
|
||||
return;
|
||||
}
|
||||
// When the layout has changed, we will update the root, but only when it is not the same
|
||||
@@ -140,20 +151,17 @@ export class FormView extends Disposable {
|
||||
} else {
|
||||
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
|
||||
}
|
||||
|
||||
// Remove the orginal box from the clipboard.
|
||||
const cutted = this._root.find(boxInClipboard.id);
|
||||
cutted?.removeSelf();
|
||||
|
||||
// Remove the original box from the clipboard.
|
||||
const cut = this._root.get(boxInClipboard.id);
|
||||
cut?.removeSelf();
|
||||
await this._root.save();
|
||||
|
||||
await navigator.clipboard.writeText('');
|
||||
};
|
||||
doPast().catch(reportError);
|
||||
},
|
||||
nextField: () => {
|
||||
const current = this.selectedBox.get();
|
||||
const all = [...this._root.list()];
|
||||
const all = [...this._root.iterate()];
|
||||
if (!all.length) { return; }
|
||||
if (!current) {
|
||||
this.selectedBox.set(all[0]);
|
||||
@@ -168,7 +176,7 @@ export class FormView extends Disposable {
|
||||
},
|
||||
prevField: () => {
|
||||
const current = this.selectedBox.get();
|
||||
const all = [...this._root.list()];
|
||||
const all = [...this._root.iterate()];
|
||||
if (!all.length) { return; }
|
||||
if (!current) {
|
||||
this.selectedBox.set(all[all.length - 1]);
|
||||
@@ -182,12 +190,12 @@ export class FormView extends Disposable {
|
||||
}
|
||||
},
|
||||
lastField: () => {
|
||||
const all = [...this._root.list()];
|
||||
const all = [...this._root.iterate()];
|
||||
if (!all.length) { return; }
|
||||
this.selectedBox.set(all[all.length - 1]);
|
||||
},
|
||||
firstField: () => {
|
||||
const all = [...this._root.list()];
|
||||
const all = [...this._root.iterate()];
|
||||
if (!all.length) { return; }
|
||||
this.selectedBox.set(all[0]);
|
||||
},
|
||||
@@ -204,39 +212,74 @@ export class FormView extends Disposable {
|
||||
await selected.deleteSelf();
|
||||
}).catch(reportError);
|
||||
},
|
||||
insertFieldBefore: (type: {field: BoxType} | {structure: BoxType}) => {
|
||||
hideFields: (colId: [string]) => {
|
||||
// Get the ref from colId.
|
||||
const existing: Array<[number, string]> =
|
||||
this.viewSection.viewFields().all().map(f => [f.id(), f.column().colId()]);
|
||||
const ref = existing.filter(([_, c]) => colId.includes(c)).map(([r, _]) => r);
|
||||
if (!ref.length) { return; }
|
||||
const box = Array.from(this._root.filter(b => ref.includes(b.prop('leaf')?.get())));
|
||||
box.forEach(b => b.removeSelf());
|
||||
this._root.save(async () => {
|
||||
await this.viewSection.removeField(ref);
|
||||
}).catch(reportError);
|
||||
},
|
||||
insertFieldBefore: (what: NewBox) => {
|
||||
const selected = this.selectedBox.get();
|
||||
if (!selected) { return; }
|
||||
if ('field' in type) {
|
||||
this.addNewQuestion(selected.placeBeforeMe(), type.field).catch(reportError);
|
||||
if ('add' in what || 'show' in what) {
|
||||
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
|
||||
} else {
|
||||
selected.insertBefore(components.defaultElement(type.structure));
|
||||
selected.insertBefore(components.defaultElement(what.structure));
|
||||
}
|
||||
},
|
||||
insertFieldAfter: (type: {field: BoxType} | {structure: BoxType}) => {
|
||||
insertField: (what: NewBox) => {
|
||||
const selected = this.selectedBox.get();
|
||||
if (!selected) { return; }
|
||||
if ('field' in type) {
|
||||
this.addNewQuestion(selected.placeAfterMe(), type.field).catch(reportError);
|
||||
const place = selected.placeAfterListChild();
|
||||
if ('add' in what || 'show' in what) {
|
||||
this.addNewQuestion(place, what).catch(reportError);
|
||||
} else {
|
||||
selected.insertAfter(components.defaultElement(type.structure));
|
||||
place(components.defaultElement(what.structure));
|
||||
this.save().catch(reportError);
|
||||
}
|
||||
},
|
||||
insertFieldAfter: (what: NewBox) => {
|
||||
const selected = this.selectedBox.get();
|
||||
if (!selected) { return; }
|
||||
if ('add' in what || 'show' in what) {
|
||||
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
|
||||
} else {
|
||||
selected.insertAfter(components.defaultElement(what.structure));
|
||||
}
|
||||
},
|
||||
showColumns: (colIds: string[]) => {
|
||||
this.bundle(async () => {
|
||||
// Sanity check that type is correct.
|
||||
if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); }
|
||||
this._root.save(async () => {
|
||||
const boxes: Box[] = [];
|
||||
for (const colId of colIds) {
|
||||
const fieldRef = await this.viewSection.showColumn(colId);
|
||||
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
|
||||
if (!field) { continue; }
|
||||
const box = {
|
||||
type: field.pureType.peek() as BoxType,
|
||||
leaf: fieldRef,
|
||||
type: 'Field' as BoxType,
|
||||
};
|
||||
boxes.push(box);
|
||||
}
|
||||
boxes.forEach(b => this._root.append(b));
|
||||
await this._saveNow();
|
||||
// Add to selected or last section, or root.
|
||||
const selected = this.selectedBox.get();
|
||||
if (selected instanceof components.SectionModel) {
|
||||
boxes.forEach(b => selected.append(b));
|
||||
} else {
|
||||
const topLevel = this._root.kids().reverse().find(b => b instanceof components.SectionModel);
|
||||
if (topLevel) {
|
||||
boxes.forEach(b => topLevel.append(b));
|
||||
} else {
|
||||
boxes.forEach(b => this._root.append(b));
|
||||
}
|
||||
}
|
||||
}).catch(reportError);
|
||||
},
|
||||
};
|
||||
@@ -250,6 +293,7 @@ export class FormView extends Disposable {
|
||||
shiftUp: keyboardActions.firstField,
|
||||
editField: keyboardActions.edit,
|
||||
deleteFields: keyboardActions.clearValues,
|
||||
hideFields: keyboardActions.hideFields,
|
||||
}, this, this.viewSection.hasFocus));
|
||||
|
||||
this._url = Computed.create(this, use => {
|
||||
@@ -273,8 +317,15 @@ export class FormView extends Disposable {
|
||||
this._remoteShare = AsyncComputed.create(this, async (use) => {
|
||||
const share = use(this._pageShare);
|
||||
if (!share) { return null; }
|
||||
const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId));
|
||||
return remoteShare ?? null;
|
||||
try {
|
||||
const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId));
|
||||
return remoteShare ?? null;
|
||||
} catch(ex) {
|
||||
// TODO: for now ignore the error, but the UI should be updated to not show editor
|
||||
// for non owners.
|
||||
if (ex.code === 'AUTH_NO_OWNER') { return null; }
|
||||
throw ex;
|
||||
}
|
||||
});
|
||||
|
||||
this._published = Computed.create(this, use => {
|
||||
@@ -306,374 +357,60 @@ export class FormView extends Disposable {
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return dom('div.flexauto.flexvbox',
|
||||
style.cssFormEdit.cls('-preview', not(this.isEdit)),
|
||||
style.cssFormEdit.cls('', this.isEdit),
|
||||
testId('preview', not(this.isEdit)),
|
||||
testId('editor', this.isEdit),
|
||||
|
||||
dom.maybe(this.isEdit, () => style.cssFormEditBody(
|
||||
return style.cssFormView(
|
||||
testId('editor'),
|
||||
style.cssFormEditBody(
|
||||
style.cssFormContainer(
|
||||
dom.forEach(this._root.children, (child) => {
|
||||
if (!child) {
|
||||
// This shouldn't happen, and it is bad design, as columns allow nulls, where other container
|
||||
// don't. But for now, just ignore it.
|
||||
return dom('div', 'Empty node');
|
||||
}
|
||||
const element = this.renderBox(this._root.children, child);
|
||||
if (Array.isArray(element)) {
|
||||
throw new Error('Element is an array');
|
||||
}
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
const element = child.render();
|
||||
if (!(element instanceof Node)) {
|
||||
throw new Error('Element is not an HTMLElement');
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
this.buildDropzone(this, this._root.placeAfterListChild()),
|
||||
this._buildPublisher(),
|
||||
),
|
||||
)),
|
||||
dom.maybe(not(this.isEdit), () => [
|
||||
style.cssPreview(
|
||||
dom.prop('src', this._url),
|
||||
)
|
||||
]),
|
||||
this._buildSwitcher(),
|
||||
),
|
||||
dom.on('click', () => this.selectedBox.set(null))
|
||||
);
|
||||
}
|
||||
|
||||
public renderBox(owner: IDisposableOwner, box: BoxModel, ...args: IDomArgs<HTMLElement>): HTMLElement {
|
||||
const overlay = Observable.create(owner, true);
|
||||
|
||||
return this.buildEditor(owner, {box, overlay},
|
||||
dom.domComputedOwned(box.type, (scope, type) => {
|
||||
const renderedElement = box.render({overlay});
|
||||
const element = renderedElement;
|
||||
return dom.update(
|
||||
element,
|
||||
testId('element'),
|
||||
testId(box.type),
|
||||
...args,
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public buildDropzone(owner: IDisposableOwner, insert: Place, ...args: IDomArgs) {
|
||||
const dragHover = Observable.create(owner, false);
|
||||
const forceShow = Observable.create(owner, false);
|
||||
return style.cssAddElement(
|
||||
testId('dropzone'),
|
||||
style.cssDrag(),
|
||||
style.cssAddText(),
|
||||
this.buildAddMenu(insert, {
|
||||
onOpen: () => forceShow.set(true),
|
||||
onClose: () => forceShow.set(false),
|
||||
}),
|
||||
style.cssAddElement.cls('-hover', use => use(dragHover)),
|
||||
// And drop zone handlers
|
||||
dom.on('drop', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
dragHover.set(false);
|
||||
|
||||
// Get the box that was dropped.
|
||||
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
|
||||
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
|
||||
const droppedRef = this._root.find(droppedId);
|
||||
|
||||
await this.bundle(async () => {
|
||||
// Save the layout if it is not saved yet.
|
||||
await this._saveNow();
|
||||
// Remove the orginal box from the clipboard.
|
||||
droppedRef?.removeSelf();
|
||||
await insert(dropped).onDrop();
|
||||
|
||||
// Save the change.
|
||||
await this._saveNow();
|
||||
});
|
||||
}),
|
||||
dom.on('dragover', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
dragHover.set(true);
|
||||
}),
|
||||
dom.on('dragleave', (ev) => {
|
||||
ev.preventDefault();
|
||||
dragHover.set(false);
|
||||
}),
|
||||
style.cssAddElement.cls('-hover', dragHover),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
public buildFieldPanel() {
|
||||
return dom('div', 'Hello there');
|
||||
}
|
||||
|
||||
public buildEditor(
|
||||
owner: IDisposableOwner | null,
|
||||
options: {
|
||||
box: BoxModel,
|
||||
overlay: Observable<boolean>
|
||||
}
|
||||
,
|
||||
...args: IDomArgs
|
||||
) {
|
||||
const {box, overlay} = options;
|
||||
const myOwner = new MultiHolder();
|
||||
if (owner) {
|
||||
owner.autoDispose(myOwner);
|
||||
}
|
||||
|
||||
let element: HTMLElement;
|
||||
const dragHover = Observable.create(myOwner, false);
|
||||
|
||||
myOwner.autoDispose(this.selectedBox.addListener(v => {
|
||||
if (v !== box) { return; }
|
||||
if (!element) { return; }
|
||||
element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
|
||||
}));
|
||||
|
||||
const isSelected = Computed.create(myOwner, use => {
|
||||
if (!this.viewSection || this.viewSection.isDisposed()) { return false; }
|
||||
if (use(this.selectedBox) === box) {
|
||||
// We are only selected when the section is also selected.
|
||||
return use(this.viewSection.hasFocus);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return style.cssFieldEditor(
|
||||
testId('editor'),
|
||||
style.cssDrag(),
|
||||
|
||||
dom.maybe(overlay, () => this.buildOverlay(myOwner, box)),
|
||||
|
||||
owner ? null : dom.autoDispose(myOwner),
|
||||
(el) => { element = el; },
|
||||
// Control panel
|
||||
style.cssControls(
|
||||
style.cssControlsLabel(dom.text(box.type)),
|
||||
),
|
||||
|
||||
// Turn on active like state when we clicked here.
|
||||
style.cssFieldEditor.cls('-selected', isSelected),
|
||||
style.cssFieldEditor.cls('-cut', use => use(box.cut)),
|
||||
testId('field-editor-selected', isSelected),
|
||||
|
||||
// Select on click.
|
||||
(el) => {
|
||||
dom.onElem(el, 'click', (ev) => {
|
||||
// Only if the click was in this element.
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest) { return; }
|
||||
// Make sure that the closest editor is this one.
|
||||
const closest = target.closest(`.${style.cssFieldEditor.className}`);
|
||||
if (closest !== el) { return; }
|
||||
|
||||
// It looks like we clicked somewhere in this editor, and not inside any other inside.
|
||||
this.selectedBox.set(box);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
});
|
||||
},
|
||||
|
||||
// Attach menu
|
||||
menus.menu((ctl) => {
|
||||
this.menuHolder.autoDispose(ctl);
|
||||
this.selectedBox.set(box);
|
||||
const field = (type: string) => ({field: type});
|
||||
const struct = (structure: string) => ({structure});
|
||||
const above = (el: {field: string} | {structure: string}) => () => allCommands.insertFieldBefore.run(el);
|
||||
const below: typeof above = (el) => () => allCommands.insertFieldAfter.run(el);
|
||||
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
|
||||
const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
|
||||
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
|
||||
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
|
||||
const insertMenu = (where: typeof above) => () => {
|
||||
return [
|
||||
menus.menuSubHeader('New question'),
|
||||
...commonTypes()
|
||||
.filter(isQuick)
|
||||
.map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName))
|
||||
,
|
||||
menus.menuItemSubmenu(
|
||||
() => commonTypes()
|
||||
.filter(notQuick)
|
||||
.map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)),
|
||||
{},
|
||||
menus.menuIcon('Dots'),
|
||||
dom('span', "More", dom.style('margin-right', '8px'))
|
||||
),
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeader('Static element'),
|
||||
menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
|
||||
menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
|
||||
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
|
||||
// menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
return [
|
||||
menus.menuItemSubmenu(insertMenu(above), {action: above(field('Text'))}, "Insert question above"),
|
||||
menus.menuItemSubmenu(insertMenu(below), {action: below(field('Text'))}, "Insert question below"),
|
||||
menus.menuDivider(),
|
||||
menus.menuItemCmd(allCommands.contextMenuCopy, "Copy"),
|
||||
menus.menuItemCmd(allCommands.contextMenuCut, "Cut"),
|
||||
menus.menuItemCmd(allCommands.contextMenuPaste, "Paste"),
|
||||
menus.menuDivider(),
|
||||
menus.menuItemCmd(allCommands.deleteFields, "Hide"),
|
||||
];
|
||||
}, {trigger: ['contextmenu']}),
|
||||
|
||||
dom.on('contextmenu', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}),
|
||||
|
||||
// And now drag and drop support.
|
||||
{draggable: "true"},
|
||||
|
||||
// When started, we just put the box into the dataTransfer as a plain text.
|
||||
// TODO: this might be very sofisticated in the future.
|
||||
dom.on('dragstart', (ev) => {
|
||||
// Prevent propagation, as we might be in a nested editor.
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON()));
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
}),
|
||||
|
||||
dom.on('dragover', (ev) => {
|
||||
// As usual, prevent propagation.
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
// Here we just change the style of the element.
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
dragHover.set(true);
|
||||
}),
|
||||
|
||||
dom.on('dragleave', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
// Just remove the style and stop propagation.
|
||||
dragHover.set(false);
|
||||
}),
|
||||
|
||||
dom.on('drop', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
dragHover.set(false);
|
||||
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
if (droppedId === box.id) { return; }
|
||||
const droppedRef = this._root.find(droppedId);
|
||||
await this.bundle(async () => {
|
||||
await this._root.save();
|
||||
droppedRef?.removeSelf();
|
||||
await box.drop(dropped)?.onDrop();
|
||||
await this._saveNow();
|
||||
});
|
||||
}),
|
||||
|
||||
style.cssFieldEditor.cls('-drag-hover', dragHover),
|
||||
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
public buildOverlay(owner: IDisposableOwner, box: BoxModel) {
|
||||
public buildOverlay(...args: IDomArgs) {
|
||||
return style.cssSelectedOverlay(
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
public async addNewQuestion(insert: Place, type: string) {
|
||||
public async addNewQuestion(insert: Place, action: {add: string}|{show: string}) {
|
||||
await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => {
|
||||
// First save the layout, so that
|
||||
await this._saveNow();
|
||||
// Now that the layout is saved, we won't be bottered with autogenerated layout,
|
||||
// First save the layout, so that we don't have autogenerated layout.
|
||||
await this.save();
|
||||
// Now that the layout is saved, we won't be bothered with autogenerated layout,
|
||||
// and we can safely insert to column.
|
||||
const {fieldRef} = await this.insertColumn(null, {
|
||||
colInfo: {
|
||||
type,
|
||||
}
|
||||
});
|
||||
|
||||
let fieldRef = 0;
|
||||
if ('show' in action) {
|
||||
fieldRef = await this.showColumn(action.show);
|
||||
} else {
|
||||
const result = await this.insertColumn(null, {
|
||||
colInfo: {
|
||||
type: action.add,
|
||||
}
|
||||
});
|
||||
fieldRef = result.fieldRef;
|
||||
}
|
||||
// And add it into the layout.
|
||||
this.selectedBox.set(insert({
|
||||
leaf: fieldRef,
|
||||
type: 'Field'
|
||||
}));
|
||||
|
||||
await this._root.save();
|
||||
}, {nestInActiveBundle: true});
|
||||
}
|
||||
|
||||
public buildAddMenu(insert: Place, {
|
||||
onClose: onClose = () => {},
|
||||
onOpen: onOpen = () => {},
|
||||
customItems = [] as Element[],
|
||||
} = {}) {
|
||||
return menus.menu(
|
||||
(ctl) => {
|
||||
onOpen();
|
||||
ctl.onDispose(onClose);
|
||||
|
||||
const field = (colType: BoxType) => ({field: colType});
|
||||
const struct = (structure: BoxType) => ({structure});
|
||||
const where = (el: {field: string} | {structure: BoxType}) => () => {
|
||||
if ('field' in el) {
|
||||
return this.addNewQuestion(insert, el.field);
|
||||
} else {
|
||||
insert(components.defaultElement(el.structure));
|
||||
return this._root.save();
|
||||
}
|
||||
};
|
||||
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
|
||||
const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
|
||||
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
|
||||
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
|
||||
return [
|
||||
menus.menuSubHeader('New question'),
|
||||
...commonTypes()
|
||||
.filter(isQuick)
|
||||
.map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName))
|
||||
,
|
||||
menus.menuItemSubmenu(
|
||||
() => commonTypes()
|
||||
.filter(notQuick)
|
||||
.map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)),
|
||||
{},
|
||||
menus.menuIcon('Dots'),
|
||||
dom('span', "More", dom.style('margin-right', '8px'))
|
||||
),
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeader('Static element'),
|
||||
menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
|
||||
menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
|
||||
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
|
||||
// menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ),
|
||||
elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}),
|
||||
customItems.length ? menus.menuDivider(dom.style('min-width', '200px')) : null,
|
||||
...customItems,
|
||||
];
|
||||
},
|
||||
{
|
||||
selectOnOpen: true,
|
||||
trigger: [
|
||||
'click',
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async _saveNow() {
|
||||
public async save() {
|
||||
try {
|
||||
this._saving = true;
|
||||
const newVersion = {...this._root.toJSON()};
|
||||
@@ -690,13 +427,27 @@ export class FormView extends Disposable {
|
||||
confirmModal(t('Publish your form?'),
|
||||
t('Publish'),
|
||||
async () => {
|
||||
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
|
||||
const page = this.viewSection.view().page();
|
||||
if (!page) {
|
||||
throw new Error('Unable to publish form: undefined page');
|
||||
const page = this.viewSection.view().page();
|
||||
if (!page) {
|
||||
throw new Error('Unable to publish form: undefined page');
|
||||
}
|
||||
let validShare = page.shareRef() !== 0;
|
||||
// If page is shared, make sure home server is aware of it.
|
||||
if (validShare) {
|
||||
try {
|
||||
const pageShare = page.share();
|
||||
const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());
|
||||
validShare = !!serverShare;
|
||||
} catch(ex) {
|
||||
// TODO: for now ignore the error, but the UI should be updated to not show editor
|
||||
if (ex.code === 'AUTH_NO_OWNER') {
|
||||
return;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
|
||||
if (page.shareRef() === 0) {
|
||||
}
|
||||
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
|
||||
if (!validShare) {
|
||||
const shareRef = await this.gristDoc.docModel.docData.sendAction([
|
||||
'AddRecord',
|
||||
'_grist_Shares',
|
||||
@@ -715,6 +466,7 @@ export class FormView extends Disposable {
|
||||
await share.optionsObj.save();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
this.viewSection.shareOptionsObj.update({
|
||||
form: true,
|
||||
publish: true,
|
||||
@@ -780,31 +532,34 @@ export class FormView extends Disposable {
|
||||
},
|
||||
);
|
||||
}
|
||||
private _buildSwitcher() {
|
||||
|
||||
const toggle = (val: boolean) => () => {
|
||||
this.isEdit.set(val);
|
||||
this._saveNow().catch(reportError);
|
||||
};
|
||||
|
||||
private _buildPublisher() {
|
||||
return style.cssSwitcher(
|
||||
this._buildSwitcherMessage(),
|
||||
style.cssButtonGroup(
|
||||
style.cssIconButton(
|
||||
icon('Pencil'),
|
||||
testId('edit'),
|
||||
dom('div', 'Editor'),
|
||||
cssButton.cls('-primary', this.isEdit),
|
||||
style.cssIconButton.cls('-standard', not(this.isEdit)),
|
||||
dom.on('click', toggle(true))
|
||||
style.cssIconButton.cls('-frameless'),
|
||||
icon('Revert'),
|
||||
testId('reset'),
|
||||
dom('div', 'Reset form'),
|
||||
dom.style('margin-right', 'auto'), // move it to the left
|
||||
dom.on('click', () => {
|
||||
this._resetForm().catch(reportError);
|
||||
})
|
||||
),
|
||||
style.cssIconButton(
|
||||
icon('EyeShow'),
|
||||
dom('div', 'Preview'),
|
||||
style.cssIconLink(
|
||||
testId('preview'),
|
||||
cssButton.cls('-primary', not(this.isEdit)),
|
||||
style.cssIconButton.cls('-standard', (this.isEdit)),
|
||||
dom.on('click', toggle(false))
|
||||
icon('EyeShow'),
|
||||
dom.text('Preview'),
|
||||
dom.prop('href', this._url),
|
||||
dom.prop('target', '_blank'),
|
||||
dom.on('click', async (ev) => {
|
||||
// If this form is not yet saved, we will save it first.
|
||||
if (!this._savedLayout) {
|
||||
stopEvent(ev);
|
||||
await this.save();
|
||||
window.open(this._url.get());
|
||||
}
|
||||
})
|
||||
),
|
||||
style.cssIconButton(
|
||||
icon('FieldAttachment'),
|
||||
@@ -831,6 +586,10 @@ export class FormView extends Disposable {
|
||||
});
|
||||
await copyToClipboard(url);
|
||||
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
|
||||
} catch(ex) {
|
||||
if (ex.code === 'AUTH_NO_OWNER') {
|
||||
throw new Error('Publishing form is only available to owners');
|
||||
}
|
||||
} finally {
|
||||
this._copyingLink.set(false);
|
||||
}
|
||||
@@ -876,8 +635,81 @@ export class FormView extends Disposable {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a form template based on the fields in the view section.
|
||||
*/
|
||||
private _formTemplate(fields: ViewFieldRec[]) {
|
||||
const boxes: Box[] = fields.map(f => {
|
||||
return {
|
||||
type: 'Field',
|
||||
leaf: f.id()
|
||||
} as Box;
|
||||
});
|
||||
const section = {
|
||||
type: 'Section',
|
||||
children: [
|
||||
{type: 'Paragraph', text: SECTION_TITLE},
|
||||
{type: 'Paragraph', text: SECTION_DESC},
|
||||
...boxes,
|
||||
],
|
||||
};
|
||||
return {
|
||||
type: 'Layout',
|
||||
children: [
|
||||
{type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
|
||||
{type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
|
||||
section,
|
||||
{type: 'Submit'}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private async _resetForm() {
|
||||
this.selectedBox.set(null);
|
||||
await this.gristDoc.docData.bundleActions('Reset form', async () => {
|
||||
// First we will remove all fields from this section, and add top 9 back.
|
||||
const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());
|
||||
|
||||
const toAdd = this.viewSection.table().columns().peek().filter(c => {
|
||||
// If hidden than no.
|
||||
if (c.isHiddenCol()) { return false; }
|
||||
|
||||
// If formula column, no.
|
||||
if (c.isFormula() && c.formula()) { return false; }
|
||||
|
||||
return true;
|
||||
});
|
||||
toAdd.sort((a, b) => a.parentPos() - b.parentPos());
|
||||
|
||||
const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());
|
||||
const parentId = colRef.map(() => this.viewSection.id());
|
||||
const parentPos = colRef.map((_, i) => i + 1);
|
||||
const ids = colRef.map(() => null);
|
||||
|
||||
await this.gristDoc.docData.sendActions([
|
||||
['BulkRemoveRecord', '_grist_Views_section_field', toDelete],
|
||||
['BulkAddRecord', '_grist_Views_section_field', ids, {
|
||||
colRef,
|
||||
parentId,
|
||||
parentPos,
|
||||
}],
|
||||
]);
|
||||
|
||||
const fields = this.viewSection.viewFields().all().slice(0, 9);
|
||||
await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
|
||||
defaults(FormView.prototype, BaseView.prototype);
|
||||
Object.assign(FormView.prototype, BackboneEvents);
|
||||
|
||||
// Default values when form is reset.
|
||||
const FORM_TITLE = "## **My Super Form**";
|
||||
const FORM_DESC = "This is the UI design work in progress on Grist Forms. We are working hard to " +
|
||||
"give you the best possible experience with this feature";
|
||||
|
||||
const SECTION_TITLE = '### **Header**';
|
||||
const SECTION_DESC = 'Description';
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {cssButton} from 'app/client/ui2018/buttons';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {cssDragger} from 'app/client/ui2018/draggableList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {Disposable, dom, fromKo, makeTestId, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const testId = makeTestId('test-vfc-');
|
||||
const t = makeT('VisibleFieldsConfig');
|
||||
|
||||
/**
|
||||
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
|
||||
* the ability to drag and drop fields onto the form.
|
||||
*/
|
||||
export class HiddenQuestionConfig extends Disposable {
|
||||
|
||||
constructor(private _section: ViewSectionRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const hiddenColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
|
||||
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
|
||||
return this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
|
||||
})));
|
||||
return [
|
||||
cssHeader(
|
||||
cssFieldListHeader(dom.text(t("Hidden fields"))),
|
||||
),
|
||||
dom('div',
|
||||
testId('hidden-fields'),
|
||||
dom.forEach(hiddenColumns, (field) => {
|
||||
return this._buildHiddenFieldItem(field);
|
||||
})
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
private _buildHiddenFieldItem(column: ColumnRec) {
|
||||
return cssDragRow(
|
||||
testId('hidden-field'),
|
||||
{draggable: "true"},
|
||||
dom.on('dragstart', (ev) => {
|
||||
// Prevent propagation, as we might be in a nested editor.
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer?.setData('text/plain', JSON.stringify({
|
||||
type: 'Field',
|
||||
leaf: column.colId.peek(), // TODO: convert to Field
|
||||
}));
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
}),
|
||||
cssSimpleDragger(),
|
||||
cssFieldEntry(
|
||||
cssFieldLabel(dom.text(column.label)),
|
||||
cssHideIcon('EyeShow',
|
||||
testId('hide'),
|
||||
dom.on('click', () => {
|
||||
allCommands.showColumns.run([column.colId.peek()]);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TODO: reuse them
|
||||
const cssDragRow = styled('div', `
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
margin: 0 16px 0px 0px;
|
||||
margin-bottom: 2px;
|
||||
cursor: grab;
|
||||
`);
|
||||
|
||||
const cssFieldEntry = styled('div', `
|
||||
display: flex;
|
||||
background-color: ${theme.hover};
|
||||
border-radius: 2px;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 4px 8px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
|
||||
--icon-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssSimpleDragger = styled(cssDragger, `
|
||||
cursor: grab;
|
||||
.${cssDragRow.className}:hover & {
|
||||
visibility: visible;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHideIcon = styled(icon, `
|
||||
--icon-color: ${theme.lightText};
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
.${cssFieldEntry.className}:hover & {
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFieldLabel = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 auto;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssFieldListHeader = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 0px;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
text-transform: uppercase;
|
||||
`);
|
||||
|
||||
const cssRow = styled('div', `
|
||||
display: flex;
|
||||
margin: 16px;
|
||||
overflow: hidden;
|
||||
--icon-color: ${theme.lightText};
|
||||
& > .${cssButton.className} {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHeader = styled(cssRow, `
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
85
app/client/components/Forms/Label.ts
Normal file
85
app/client/components/Forms/Label.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as css from './styles';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import {stopEvent} from 'app/client/lib/domUtils';
|
||||
import {not} from 'app/common/gutil';
|
||||
import {Computed, dom, Observable} from 'grainjs';
|
||||
|
||||
export class LabelModel extends BoxModel {
|
||||
public edit = Observable.create(this, false);
|
||||
|
||||
protected defaultValue = '';
|
||||
|
||||
public render(): HTMLElement {
|
||||
let element: HTMLTextAreaElement;
|
||||
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
|
||||
const cssClass = this.prop('cssClass', '') as Observable<string>;
|
||||
const editableText = Observable.create(this, text.get() || '');
|
||||
const overlay = Computed.create(this, use => !use(this.edit));
|
||||
|
||||
this.autoDispose(text.addListener((v) => editableText.set(v || '')));
|
||||
|
||||
const save = (ok: boolean) => {
|
||||
if (ok) {
|
||||
text.set(editableText.get());
|
||||
void this.parent?.save().catch(reportError);
|
||||
} else {
|
||||
editableText.set(text.get() || '');
|
||||
}
|
||||
};
|
||||
|
||||
const mode = (edit: boolean) => {
|
||||
if (this.isDisposed() || this.edit.isDisposed()) { return; }
|
||||
if (this.edit.get() === edit) { return; }
|
||||
this.edit.set(edit);
|
||||
};
|
||||
|
||||
return buildEditor(
|
||||
{
|
||||
box: this,
|
||||
editMode: this.edit,
|
||||
overlay,
|
||||
click: (ev) => {
|
||||
stopEvent(ev);
|
||||
// If selected, then edit.
|
||||
if (!this.selected.get()) { return; }
|
||||
if (document.activeElement === element) { return; }
|
||||
editableText.set(text.get() || '');
|
||||
this.edit.set(true);
|
||||
setTimeout(() => {
|
||||
element.focus();
|
||||
element.select();
|
||||
}, 10);
|
||||
},
|
||||
content: element = css.cssEditableLabel(
|
||||
editableText,
|
||||
{onInput: true, autoGrow: true},
|
||||
{placeholder: `Empty label`},
|
||||
dom.on('click', ev => {
|
||||
stopEvent(ev);
|
||||
}),
|
||||
// Styles saved (for titles and such)
|
||||
css.cssEditableLabel.cls(use => `-${use(cssClass)}`),
|
||||
// Disable editing if not in edit mode.
|
||||
dom.boolAttr('readonly', not(this.edit)),
|
||||
// Pass edit to css.
|
||||
css.cssEditableLabel.cls('-edit', this.edit),
|
||||
// Attach default save controls (Enter, Esc) and so on.
|
||||
css.saveControls(this.edit, save),
|
||||
// Turn off resizable for textarea.
|
||||
dom.style('resize', 'none'),
|
||||
),
|
||||
},
|
||||
dom.onKeyDown({Enter$: (ev) => {
|
||||
// If no in edit mode, change it.
|
||||
if (!this.edit.get()) {
|
||||
mode(true);
|
||||
ev.stopPropagation();
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
}})
|
||||
);
|
||||
}
|
||||
}
|
||||
170
app/client/components/Forms/Menu.ts
Normal file
170
app/client/components/Forms/Menu.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {BoxModel, BoxType, Place} from 'app/client/components/Forms/Model';
|
||||
import {makeTestId, stopEvent} from 'app/client/lib/domUtils';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
|
||||
import * as menus from 'app/client/ui2018/menus';
|
||||
import * as components from 'app/client/components/Forms/elements';
|
||||
import {Computed, dom, IDomArgs, MultiHolder} from 'grainjs';
|
||||
|
||||
const t = makeT('FormView');
|
||||
const testId = makeTestId('test-forms-menu-');
|
||||
|
||||
// New box to add, either a new column of type, an existing column (by column id), or a structure.
|
||||
export type NewBox = {add: string} | {show: string} | {structure: BoxType};
|
||||
|
||||
interface Props {
|
||||
box?: BoxModel;
|
||||
view?: FormView;
|
||||
context?: boolean;
|
||||
customItems?: Element[],
|
||||
insertBox?: Place
|
||||
}
|
||||
|
||||
export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArgs<HTMLElement> {
|
||||
const {box, context, customItems} = props;
|
||||
const view = box?.view ?? props.view;
|
||||
if (!view) { throw new Error("No view provided"); }
|
||||
const gristDoc = view.gristDoc;
|
||||
const viewSection = view.viewSection;
|
||||
const owner = new MultiHolder();
|
||||
|
||||
const unmapped = Computed.create(owner, (use) => {
|
||||
const types = getNewColumnTypes(gristDoc, use(viewSection.tableId));
|
||||
const normalCols = use(viewSection.hiddenColumns).filter(col => {
|
||||
if (use(col.isHiddenCol)) { return false; }
|
||||
if (use(col.isFormula) && use(col.formula)) { return false; }
|
||||
if (use(col.pureType) === 'Attachments') { return false; }
|
||||
return true;
|
||||
});
|
||||
const list = normalCols.map(col => {
|
||||
return {
|
||||
label: use(col.label),
|
||||
icon: types.find(type => type.colType === use(col.pureType))?.icon ?? 'TypeCell',
|
||||
colId: use(col.colId),
|
||||
};
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
const oneTo5 = Computed.create(owner, (use) => use(unmapped).length > 0 && use(unmapped).length <= 5);
|
||||
const moreThan5 = Computed.create(owner, (use) => use(unmapped).length > 5);
|
||||
|
||||
return [
|
||||
dom.autoDispose(owner),
|
||||
menus.menu((ctl) => {
|
||||
box?.view.selectedBox.set(box);
|
||||
|
||||
// Same for structure.
|
||||
const struct = (structure: BoxType) => ({structure});
|
||||
|
||||
// Actions:
|
||||
|
||||
// Insert field before and after.
|
||||
const above = (el: NewBox) => () => {
|
||||
allCommands.insertFieldBefore.run(el);
|
||||
};
|
||||
const below = (el: NewBox) => () => {
|
||||
allCommands.insertFieldAfter.run(el);
|
||||
};
|
||||
const atEnd = (el: NewBox) => () => {
|
||||
allCommands.insertField.run(el);
|
||||
};
|
||||
const custom = props.insertBox ? (el: NewBox) => () => {
|
||||
if ('add' in el || 'show' in el) {
|
||||
return view.addNewQuestion(props.insertBox!, el);
|
||||
} else {
|
||||
props.insertBox!(components.defaultElement(el.structure));
|
||||
return view.save();
|
||||
}
|
||||
} : null;
|
||||
|
||||
// Field menus.
|
||||
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
|
||||
const disabled = ['Attachments'];
|
||||
const commonTypes = () => getNewColumnTypes(gristDoc, viewSection.tableId());
|
||||
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
|
||||
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
|
||||
const isEnabled = ({colType}: {colType: string}) => !disabled.includes(colType);
|
||||
|
||||
const insertMenu = (where: typeof above) => () => {
|
||||
return [
|
||||
menus.menuSubHeader('New question'),
|
||||
...commonTypes()
|
||||
.filter(isQuick)
|
||||
.filter(isEnabled)
|
||||
.map(ct => menus.menuItem(where({add: ct.colType}), menus.menuIcon(ct.icon!), ct.displayName))
|
||||
,
|
||||
menus.menuItemSubmenu(
|
||||
() => commonTypes()
|
||||
.filter(notQuick)
|
||||
.filter(isEnabled)
|
||||
.map(ct => menus.menuItem(
|
||||
where({add: ct.colType}),
|
||||
menus.menuIcon(ct.icon!),
|
||||
ct.displayName,
|
||||
)),
|
||||
{},
|
||||
menus.menuIcon('Dots'),
|
||||
dom('span', "More", dom.style('margin-right', '8px'))
|
||||
),
|
||||
dom.maybe(oneTo5, () => [
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeader(t('Unmapped fields')),
|
||||
dom.domComputed(unmapped, (uf) =>
|
||||
uf.map(({label, icon, colId}) => menus.menuItem(
|
||||
where({show: colId}),
|
||||
menus.menuIcon(icon),
|
||||
label,
|
||||
testId('unmapped'),
|
||||
testId('unmapped-' + colId)
|
||||
)),
|
||||
),
|
||||
]),
|
||||
dom.maybe(moreThan5, () => [
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeaderMenu(
|
||||
() => unmapped.get().map(
|
||||
({label, icon, colId}) => menus.menuItem(
|
||||
where({show: colId}),
|
||||
menus.menuIcon(icon),
|
||||
label,
|
||||
testId('unmapped'),
|
||||
testId('unmapped-' + colId)
|
||||
)),
|
||||
{},
|
||||
dom('span', "Unmapped fields", dom.style('margin-right', '8px'))
|
||||
),
|
||||
]),
|
||||
menus.menuDivider(),
|
||||
menus.menuSubHeader(t('Building blocks')),
|
||||
menus.menuItem(where(struct('Columns')), menus.menuIcon('Columns'), t("Columns")),
|
||||
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Paragraph'), t("Paragraph")),
|
||||
menus.menuItem(where(struct('Separator')), menus.menuIcon('Separator'), t("Separator")),
|
||||
];
|
||||
};
|
||||
|
||||
if (!props.context) {
|
||||
return insertMenu(custom ?? atEnd)();
|
||||
}
|
||||
|
||||
return [
|
||||
menus.menuItemSubmenu(insertMenu(above), {action: above({add: 'Text'})}, t("Insert question above")),
|
||||
menus.menuItemSubmenu(insertMenu(below), {action: below({add: 'Text'})}, t("Insert question below")),
|
||||
menus.menuDivider(),
|
||||
menus.menuItemCmd(allCommands.contextMenuCopy, t("Copy")),
|
||||
menus.menuItemCmd(allCommands.contextMenuCut, t("Cut")),
|
||||
menus.menuItemCmd(allCommands.contextMenuPaste, t("Paste")),
|
||||
menus.menuDivider(),
|
||||
menus.menuItemCmd(allCommands.deleteFields, "Hide"),
|
||||
elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}),
|
||||
customItems?.length ? menus.menuDivider(dom.style('min-width', '200px')) : null,
|
||||
...(customItems ?? []),
|
||||
...args,
|
||||
];
|
||||
}, {trigger: [context ? 'contextmenu' : 'click']}),
|
||||
context ? dom.on('contextmenu', stopEvent) : null,
|
||||
];
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import * as elements from 'app/client/components/Forms/elements';
|
||||
import {
|
||||
bundleChanges, Computed, Disposable, dom, DomContents,
|
||||
MultiHolder, MutableObsArray, obsArray, Observable
|
||||
} from 'grainjs';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {bundleChanges, Computed, Disposable, dom, IDomArgs, MutableObsArray, obsArray, Observable} from 'grainjs';
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
|
||||
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field';
|
||||
type Callback = () => Promise<void>;
|
||||
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit'
|
||||
| 'Placeholder' | 'Layout' | 'Field' | 'Label'
|
||||
| 'Separator'
|
||||
;
|
||||
|
||||
/**
|
||||
* Box model is a JSON that represents a form element. Every element can be converted to this element and every
|
||||
@@ -35,6 +35,7 @@ export abstract class BoxModel extends Disposable {
|
||||
const subClassName = `${box.type.split(':')[0]}Model`;
|
||||
const factories = elements as any;
|
||||
const factory = factories[subClassName];
|
||||
if (!parent && !view) { throw new Error('Cannot create detached box'); }
|
||||
// If we have a factory, use it.
|
||||
if (factory) {
|
||||
return new factory(box, parent, view || parent!.view);
|
||||
@@ -53,7 +54,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* Type of the box. As the type is bounded to the class that is used to render the box, it is possible
|
||||
* to change the type of the box just by changing this value. The box is then replaced in the parent.
|
||||
*/
|
||||
public type: Observable<string>;
|
||||
public type: BoxType;
|
||||
/**
|
||||
* List of children boxes.
|
||||
*/
|
||||
@@ -68,18 +69,27 @@ export abstract class BoxModel extends Disposable {
|
||||
*/
|
||||
public cut = Observable.create(this, false);
|
||||
|
||||
public selected: Observable<boolean>;
|
||||
/**
|
||||
* Don't use it directly, use the BoxModel.new factory method instead.
|
||||
*/
|
||||
constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
|
||||
super();
|
||||
|
||||
this.selected = Computed.create(this, (use) => use(view.selectedBox) === this && use(view.viewSection.hasFocus));
|
||||
|
||||
this.children = this.autoDispose(obsArray([]));
|
||||
|
||||
// We are owned by the parent children list.
|
||||
if (parent) {
|
||||
parent.children.autoDispose(this);
|
||||
}
|
||||
|
||||
// Store "pointer" to this element.
|
||||
this.id = uuidv4();
|
||||
|
||||
// Create observables for all properties.
|
||||
this.type = Observable.create(this, box.type);
|
||||
this.children = this.autoDispose(obsArray([]));
|
||||
this.type = box.type;
|
||||
|
||||
// And now update this and all children based on the box JSON.
|
||||
bundleChanges(() => {
|
||||
@@ -96,7 +106,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* this method can send some actions to the server, or do some other work. In particular Field
|
||||
* will insert or reveal a column.
|
||||
*/
|
||||
public async onDrop() {
|
||||
public async afterDrop() {
|
||||
|
||||
}
|
||||
|
||||
@@ -104,7 +114,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* The only method that derived classes need to implement. It should return a DOM element that
|
||||
* represents this box.
|
||||
*/
|
||||
public abstract render(context: RenderContext): HTMLElement;
|
||||
public abstract render(...args: IDomArgs<HTMLElement>): HTMLElement;
|
||||
|
||||
|
||||
public removeChild(box: BoxModel) {
|
||||
@@ -135,7 +145,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* Cuts self and puts it into clipboard.
|
||||
*/
|
||||
public async cutSelf() {
|
||||
[...this.root().list()].forEach(box => box?.cut.set(false));
|
||||
[...this.root().iterate()].forEach(box => box?.cut.set(false));
|
||||
// Add this box as a json to clipboard.
|
||||
await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
|
||||
this.cut.set(true);
|
||||
@@ -145,7 +155,7 @@ export abstract class BoxModel extends Disposable {
|
||||
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
|
||||
* as a first child. Default implementation is to insert before self.
|
||||
*/
|
||||
public drop(dropped: Box) {
|
||||
public accept(dropped: Box, hint: 'above'|'below' = 'above') {
|
||||
// Get the box that was dropped.
|
||||
if (!dropped) { return null; }
|
||||
if (dropped.id === this.id) {
|
||||
@@ -153,11 +163,11 @@ export abstract class BoxModel extends Disposable {
|
||||
}
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
const droppedRef = this.root().find(droppedId);
|
||||
const droppedRef = this.root().get(droppedId);
|
||||
if (droppedRef) {
|
||||
droppedRef.removeSelf();
|
||||
}
|
||||
return this.placeBeforeMe()(dropped);
|
||||
return hint === 'above' ? this.placeBeforeMe()(dropped) : this.placeAfterMe()(dropped);
|
||||
}
|
||||
|
||||
public prop(name: string, defaultValue?: any) {
|
||||
@@ -167,9 +177,13 @@ export abstract class BoxModel extends Disposable {
|
||||
return this.props[name];
|
||||
}
|
||||
|
||||
public async save(): Promise<void> {
|
||||
public hasProp(name: string) {
|
||||
return this.props.hasOwnProperty(name);
|
||||
}
|
||||
|
||||
public async save(before?: () => Promise<void>): Promise<void> {
|
||||
if (!this.parent) { throw new Error('Cannot save detached box'); }
|
||||
return this.parent.save();
|
||||
return this.parent.save(before);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,15 +266,29 @@ export abstract class BoxModel extends Disposable {
|
||||
/**
|
||||
* Finds a box with a given id in the tree.
|
||||
*/
|
||||
public find(droppedId: string): BoxModel | null {
|
||||
public get(droppedId: string): BoxModel | null {
|
||||
for (const child of this.kids()) {
|
||||
if (child.id === droppedId) { return child; }
|
||||
const found = child.find(droppedId);
|
||||
const found = child.get(droppedId);
|
||||
if (found) { return found; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public* filter(filter: (box: BoxModel) => boolean): Iterable<BoxModel> {
|
||||
for (const child of this.kids()) {
|
||||
if (filter(child)) { yield child; }
|
||||
yield* child.filter(filter);
|
||||
}
|
||||
}
|
||||
|
||||
public includes(box: BoxModel) {
|
||||
for (const child of this.kids()) {
|
||||
if (child === box) { return true; }
|
||||
if (child.includes(box)) { return true; }
|
||||
}
|
||||
}
|
||||
|
||||
public kids() {
|
||||
return this.children.get().filter(Boolean);
|
||||
}
|
||||
@@ -271,15 +299,19 @@ export abstract class BoxModel extends Disposable {
|
||||
*/
|
||||
public update(boxDef: Box) {
|
||||
// If we have a type and the type is changed, then we need to replace the box.
|
||||
if (this.type.get() && boxDef.type !== this.type.get()) {
|
||||
this.parent!.replace(this, BoxModel.new(boxDef, this.parent));
|
||||
if (this.type && boxDef.type !== this.type) {
|
||||
if (!this.parent) { throw new Error('Cannot replace detached box'); }
|
||||
this.parent.replace(this, BoxModel.new(boxDef, this.parent));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update all properties of self.
|
||||
for (const key in boxDef) {
|
||||
// Skip some keys.
|
||||
if (key === 'id' || key === 'type' || key === 'children') { continue; }
|
||||
// Skip any inherited properties.
|
||||
if (!boxDef.hasOwnProperty(key)) { continue; }
|
||||
// Skip if the value is the same.
|
||||
if (this.prop(key).get() === boxDef[key]) { continue; }
|
||||
this.prop(key).set(boxDef[key]);
|
||||
}
|
||||
@@ -296,11 +328,13 @@ export abstract class BoxModel extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
if (!boxDef.children) { return; }
|
||||
|
||||
// Update those that indices are the same.
|
||||
const min = Math.min(myLength, newLength);
|
||||
for (let i = 0; i < min; i++) {
|
||||
const atIndex = this.children.get()[i];
|
||||
const atIndexDef = boxDef.children![i];
|
||||
const atIndexDef = boxDef.children[i];
|
||||
atIndex.update(atIndexDef);
|
||||
}
|
||||
}
|
||||
@@ -311,16 +345,16 @@ export abstract class BoxModel extends Disposable {
|
||||
public toJSON(): Box {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type.get() as BoxType,
|
||||
type: this.type,
|
||||
children: this.children.get().map(child => child?.toJSON() || null),
|
||||
...(Object.fromEntries(Object.entries(this.props).map(([key, val]) => [key, val.get()]))),
|
||||
};
|
||||
}
|
||||
|
||||
public * list(): IterableIterator<BoxModel> {
|
||||
public * iterate(): IterableIterator<BoxModel> {
|
||||
for (const child of this.kids()) {
|
||||
yield child;
|
||||
yield* child.list();
|
||||
yield* child.iterate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,35 +364,30 @@ export abstract class BoxModel extends Disposable {
|
||||
}
|
||||
|
||||
export class LayoutModel extends BoxModel {
|
||||
constructor(box: Box, public parent: BoxModel | null, public _save: () => Promise<void>, public view: FormView) {
|
||||
constructor(
|
||||
box: Box,
|
||||
public parent: BoxModel | null,
|
||||
public _save: (clb?: Callback) => Promise<void>,
|
||||
public view: FormView
|
||||
) {
|
||||
super(box, parent, view);
|
||||
}
|
||||
|
||||
public async save() {
|
||||
return await this._save();
|
||||
public async save(clb?: Callback) {
|
||||
return await this._save(clb);
|
||||
}
|
||||
|
||||
public render(): HTMLElement {
|
||||
public override render(): HTMLElement {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultBoxModel extends BoxModel {
|
||||
public render(): HTMLElement {
|
||||
return dom('div', `Unknown box type ${this.type.get()}`);
|
||||
return dom('div', `Unknown box type ${this.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
overlay: Observable<boolean>,
|
||||
}
|
||||
|
||||
export type Builder = (owner: MultiHolder, options: {
|
||||
box: BoxModel,
|
||||
view: FormView,
|
||||
overlay: Observable<boolean>,
|
||||
}) => DomContents;
|
||||
|
||||
export const ignoreClick = dom.on('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -1,73 +1,64 @@
|
||||
import * as css from './styles';
|
||||
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
|
||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
||||
import {textarea} from 'app/client/ui/inputs';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {not} from 'app/common/gutil';
|
||||
import {Computed, dom, Observable, styled} from 'grainjs';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
|
||||
export class ParagraphModel extends BoxModel {
|
||||
public edit = Observable.create(this, false);
|
||||
|
||||
public render(context: RenderContext) {
|
||||
protected defaultValue = '**Lorem** _ipsum_ dolor';
|
||||
protected cssClass = '';
|
||||
|
||||
private _overlay = Computed.create(this, not(this.selected));
|
||||
|
||||
public override render(): HTMLElement {
|
||||
const box = this;
|
||||
context.overlay.set(false);
|
||||
const editMode = box.edit;
|
||||
let element: HTMLElement;
|
||||
const text = this.prop('text', '**Lorem** _ipsum_ dolor') as Observable<string|undefined>;
|
||||
const properText = Computed.create(this, (use) => {
|
||||
const savedText = use(text);
|
||||
if (!savedText) { return ''; }
|
||||
if (typeof savedText !== 'string') { return ''; }
|
||||
return savedText;
|
||||
});
|
||||
properText.onWrite((val) => {
|
||||
if (typeof val !== 'string') { return; }
|
||||
text.set(val);
|
||||
this.parent?.save().catch(reportError);
|
||||
});
|
||||
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
|
||||
|
||||
box.edit.addListener((val) => {
|
||||
if (!val) { return; }
|
||||
setTimeout(() => element.focus(), 0);
|
||||
});
|
||||
// There is a spacial hack here. We might be created as a separator component, but the rendering
|
||||
// for separator looks bad when it is the only content, so add a special case for that.
|
||||
const isSeparator = Computed.create(this, (use) => use(text) === '---');
|
||||
|
||||
return css.cssStaticText(
|
||||
css.markdown(use => use(properText) || '', dom.cls('_preview'), dom.hide(editMode)),
|
||||
dom.maybe(use => !use(properText) && !use(editMode), () => cssEmpty('(empty)')),
|
||||
dom.on('dblclick', () => {
|
||||
editMode.set(true);
|
||||
}),
|
||||
css.cssStaticText.cls('-edit', editMode),
|
||||
dom.maybe(editMode, () => [
|
||||
cssTextArea(properText, {},
|
||||
(el) => {
|
||||
element = el;
|
||||
},
|
||||
dom.onKeyDown({
|
||||
Enter$: (ev) => {
|
||||
// if shift ignore
|
||||
if (ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
editMode.set(false);
|
||||
},
|
||||
Escape$: (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
editMode.set(false);
|
||||
}
|
||||
}),
|
||||
dom.on('blur', () => {
|
||||
editMode.set(false);
|
||||
}),
|
||||
),
|
||||
])
|
||||
);
|
||||
return buildEditor({
|
||||
box: this,
|
||||
overlay: this._overlay,
|
||||
content: css.cssMarkdownRendered(
|
||||
css.markdown(use => use(text) || '', dom.hide(editMode)),
|
||||
dom.maybe(use => !use(text) && !use(editMode), () => cssEmpty('(empty)')),
|
||||
css.cssMarkdownRendered.cls('-separator', isSeparator),
|
||||
dom.on('click', () => {
|
||||
if (!editMode.get() && this.selected.get()) {
|
||||
editMode.set(true);
|
||||
}
|
||||
}),
|
||||
css.cssMarkdownRendered.cls('-edit', editMode),
|
||||
css.cssMarkdownRendered.cls(u => `-alignment-${u(box.prop('alignment', 'left'))}`),
|
||||
this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null,
|
||||
dom.maybe(editMode, () => {
|
||||
const draft = Observable.create(null, text.get() || '');
|
||||
setTimeout(() => element?.focus(), 10);
|
||||
return [
|
||||
element = cssTextArea(draft, {autoGrow: true, onInput: true},
|
||||
cssTextArea.cls('-edit', editMode),
|
||||
css.saveControls(editMode, (ok) => {
|
||||
if (ok && editMode.get()) {
|
||||
text.set(draft.get());
|
||||
this.save().catch(reportError);
|
||||
}
|
||||
})
|
||||
),
|
||||
];
|
||||
}),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const cssTextArea = styled(textarea, `
|
||||
color: ${theme.inputFg};
|
||||
background-color: ${theme.mainPanelBg};
|
||||
@@ -79,6 +70,13 @@ const cssTextArea = styled(textarea, `
|
||||
min-height: calc(3em * 1.5);
|
||||
resize: none;
|
||||
border-radius: 3px;
|
||||
&-edit {
|
||||
cursor: auto;
|
||||
background: ${theme.inputBg};
|
||||
outline: 2px solid black;
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::placeholder {
|
||||
color: ${theme.inputPlaceholderFg};
|
||||
}
|
||||
|
||||
@@ -1,25 +1,75 @@
|
||||
import * as style from './styles';
|
||||
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
|
||||
import {dom} from 'grainjs';
|
||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
||||
import {buildMenu} from 'app/client/components/Forms/Menu';
|
||||
import {Box, BoxModel} from 'app/client/components/Forms/Model';
|
||||
import {dom, styled} from 'grainjs';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
/**
|
||||
* Component that renders a section of the form.
|
||||
*/
|
||||
export class SectionModel extends BoxModel {
|
||||
public render(context: RenderContext) {
|
||||
public override render(): HTMLElement {
|
||||
const children = this.children;
|
||||
context.overlay.set(false);
|
||||
const view = this.view;
|
||||
const box = this;
|
||||
|
||||
const element = style.cssSection(
|
||||
style.cssDrag(),
|
||||
dom.forEach(children, (child) =>
|
||||
child ? view.renderBox(children, child) : dom('div', 'Empty')
|
||||
),
|
||||
view.buildDropzone(children, box.placeAfterListChild()),
|
||||
return buildEditor({
|
||||
box: this,
|
||||
// Custom drag element that is little bigger and at the top of the section.
|
||||
drag: style.cssDragWrapper(style.cssDrag('DragDrop', style.cssDrag.cls('-top'))),
|
||||
// No way to remove section now.
|
||||
removeIcon: null,
|
||||
// Content is just a list of children.
|
||||
content: style.cssSection(
|
||||
// Wrap them in a div that mutes hover events.
|
||||
cssSectionItems(
|
||||
dom.forEach(children, (child) => child.render()),
|
||||
),
|
||||
// Plus icon
|
||||
style.cssPlusButton(
|
||||
testId('plus'),
|
||||
style.cssDrop(),
|
||||
style.cssCircle(
|
||||
style.cssPlusIcon('Plus'),
|
||||
buildMenu({
|
||||
box: this,
|
||||
})
|
||||
),
|
||||
)
|
||||
)},
|
||||
style.cssSectionEditor.cls(''),
|
||||
);
|
||||
}
|
||||
|
||||
return element;
|
||||
|
||||
/**
|
||||
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
|
||||
* as a first child. Default implementation is to insert before self.
|
||||
*/
|
||||
public override accept(dropped: Box) {
|
||||
// Get the box that was dropped.
|
||||
if (!dropped) { return null; }
|
||||
if (dropped.id === this.id) {
|
||||
return null;
|
||||
}
|
||||
// We need to remove it from the parent, so find it first.
|
||||
const droppedId = dropped.id;
|
||||
const droppedRef = this.root().get(droppedId);
|
||||
if (droppedRef) {
|
||||
droppedRef.removeSelf();
|
||||
}
|
||||
|
||||
// Depending of the type of dropped box we need to insert it in different places.
|
||||
// By default we insert it before this box.
|
||||
let place = this.placeBeforeMe();
|
||||
if (dropped.type === 'Field') {
|
||||
// Fields are inserted after last child.
|
||||
place = this.placeAfterListChild();
|
||||
}
|
||||
|
||||
return place(dropped);
|
||||
}
|
||||
}
|
||||
|
||||
const cssSectionItems = styled('div.hover_border', `
|
||||
`);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {primaryButton} from 'app/client/ui2018/buttons';
|
||||
const testId = makeTestId('test-forms-');
|
||||
import { BoxModel } from "app/client/components/Forms/Model";
|
||||
import { makeTestId } from "app/client/lib/domUtils";
|
||||
import { bigPrimaryButton } from "app/client/ui2018/buttons";
|
||||
import { dom } from "grainjs";
|
||||
const testId = makeTestId("test-forms-");
|
||||
|
||||
export class SubmitModel extends BoxModel {
|
||||
public render(context: RenderContext) {
|
||||
return primaryButton('Submit', testId('submit'));
|
||||
public override render() {
|
||||
const text = this.view.viewSection.layoutSpecObj.prop('submitText');
|
||||
return dom(
|
||||
"div",
|
||||
{ style: "text-align: center; margin-top: 20px;" },
|
||||
bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as style from './styles';
|
||||
import {Builder, ignoreClick} from 'app/client/components/Forms/Model';
|
||||
import {Computed, dom, IDisposableOwner, makeTestId} from 'grainjs';
|
||||
const testId = makeTestId('test-forms-');
|
||||
|
||||
export const buildTextField: Builder = (owner: IDisposableOwner, {box, view}) => {
|
||||
|
||||
const field = Computed.create(owner, use => {
|
||||
return view.gristDoc.docModel.viewFields.getRowModel(use(box.prop('leaf')));
|
||||
});
|
||||
return dom('div',
|
||||
testId('question'),
|
||||
testId('question-Text'),
|
||||
style.cssLabel(
|
||||
testId('label'),
|
||||
dom.text(use => use(use(field).question) || use(use(field).origLabel))
|
||||
),
|
||||
style.cssInput(
|
||||
testId('input'),
|
||||
{type: 'text', tabIndex: "-1"},
|
||||
ignoreClick),
|
||||
dom.maybe(use => use(use(field).description), (description) => [
|
||||
style.cssDesc(description, testId('description')),
|
||||
]),
|
||||
);
|
||||
};
|
||||
274
app/client/components/Forms/UnmappedFieldsConfig.ts
Normal file
274
app/client/components/Forms/UnmappedFieldsConfig.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {squareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {cssDragger} from 'app/client/ui2018/draggableList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const testId = makeTestId('test-vfc-');
|
||||
const t = makeT('VisibleFieldsConfig');
|
||||
|
||||
/**
|
||||
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
|
||||
* the ability to drag and drop fields onto the form.
|
||||
*/
|
||||
export class UnmappedFieldsConfig extends Disposable {
|
||||
|
||||
constructor(private _section: ViewSectionRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const unmappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
|
||||
if (this._section.isDisposed()) {
|
||||
return [];
|
||||
}
|
||||
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
|
||||
const cols = this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
|
||||
return cols.map(col => ({
|
||||
col,
|
||||
selected: Observable.create(null, false),
|
||||
}));
|
||||
})));
|
||||
const mappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
|
||||
if (this._section.isDisposed()) {
|
||||
return [];
|
||||
}
|
||||
const cols = this._section.viewFields().map(f => f.column());
|
||||
return cols.map(col => ({
|
||||
col,
|
||||
selected: Observable.create(null, false),
|
||||
})).all();
|
||||
})));
|
||||
|
||||
const anyUnmappedSelected = Computed.create(this, use => {
|
||||
return use(unmappedColumns).some(c => use(c.selected));
|
||||
});
|
||||
|
||||
const anyMappedSelected = Computed.create(this, use => {
|
||||
return use(mappedColumns).some(c => use(c.selected));
|
||||
});
|
||||
|
||||
const mapSelected = async () => {
|
||||
await allCommands.showColumns.run(
|
||||
unmappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));
|
||||
};
|
||||
|
||||
const unMapSelected = async () => {
|
||||
await allCommands.hideFields.run(
|
||||
mappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));
|
||||
};
|
||||
|
||||
return [
|
||||
cssHeader(
|
||||
cssFieldListHeader(t("Unmapped")),
|
||||
selectAllLabel(
|
||||
dom.on('click', () => {
|
||||
unmappedColumns.get().forEach((col) => col.selected.set(true));
|
||||
}),
|
||||
dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),
|
||||
),
|
||||
),
|
||||
dom('div',
|
||||
testId('hidden-fields'),
|
||||
dom.forEach(unmappedColumns, (field) => {
|
||||
return this._buildUnmappedField(field);
|
||||
})
|
||||
),
|
||||
dom.maybe(anyUnmappedSelected, () =>
|
||||
cssRow(
|
||||
primaryButton(
|
||||
dom.text(t("Map fields")),
|
||||
dom.on('click', mapSelected),
|
||||
testId('visible-hide')
|
||||
),
|
||||
basicButton(
|
||||
t("Clear"),
|
||||
dom.on('click', () => unmappedColumns.get().forEach((col) => col.selected.set(false))),
|
||||
testId('visible-clear')
|
||||
),
|
||||
testId('visible-batch-buttons')
|
||||
),
|
||||
),
|
||||
cssHeader(
|
||||
cssFieldListHeader(dom.text(t("Mapped"))),
|
||||
selectAllLabel(
|
||||
dom.on('click', () => {
|
||||
mappedColumns.get().forEach((col) => col.selected.set(true));
|
||||
}),
|
||||
dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0),
|
||||
),
|
||||
),
|
||||
dom('div',
|
||||
testId('visible-fields'),
|
||||
dom.forEach(mappedColumns, (field) => {
|
||||
return this._buildMappedField(field);
|
||||
})
|
||||
),
|
||||
dom.maybe(anyMappedSelected, () =>
|
||||
cssRow(
|
||||
primaryButton(
|
||||
dom.text(t("Unmap fields")),
|
||||
dom.on('click', unMapSelected),
|
||||
testId('visible-hide')
|
||||
),
|
||||
basicButton(
|
||||
t("Clear"),
|
||||
dom.on('click', () => mappedColumns.get().forEach((col) => col.selected.set(false))),
|
||||
testId('visible-clear')
|
||||
),
|
||||
testId('visible-batch-buttons')
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildUnmappedField(props: {col: ColumnRec, selected: Observable<boolean>}) {
|
||||
const column = props.col;
|
||||
return cssDragRow(
|
||||
testId('hidden-field'),
|
||||
{draggable: "true"},
|
||||
dom.on('dragstart', (ev) => {
|
||||
// Prevent propagation, as we might be in a nested editor.
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer?.setData('text/plain', JSON.stringify({
|
||||
type: 'Field',
|
||||
leaf: column.colId.peek(), // TODO: convert to Field
|
||||
}));
|
||||
ev.dataTransfer!.dropEffect = "move";
|
||||
}),
|
||||
cssSimpleDragger(),
|
||||
cssFieldEntry(
|
||||
cssFieldLabel(dom.text(column.label)),
|
||||
cssHideIcon('EyeShow',
|
||||
testId('hide'),
|
||||
dom.on('click', () => {
|
||||
allCommands.showColumns.run([column.colId.peek()]);
|
||||
}),
|
||||
),
|
||||
squareCheckbox(props.selected),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private _buildMappedField(props: {col: ColumnRec, selected: Observable<boolean>}) {
|
||||
const column = props.col;
|
||||
return cssDragRow(
|
||||
testId('visible-field'),
|
||||
cssSimpleDragger(
|
||||
cssSimpleDragger.cls('-hidden'),
|
||||
),
|
||||
cssFieldEntry(
|
||||
cssFieldLabel(dom.text(column.label)),
|
||||
cssHideIcon('EyeHide',
|
||||
testId('hide'),
|
||||
dom.on('click', () => {
|
||||
allCommands.hideFields.run([column.colId.peek()]);
|
||||
}),
|
||||
),
|
||||
squareCheckbox(props.selected),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllLabel(...args: any[]) {
|
||||
return cssControlLabel(
|
||||
testId('select-all'),
|
||||
icon('Tick'),
|
||||
dom('span', t("Select All")),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
const cssControlLabel = styled('div', `
|
||||
--icon-color: ${theme.controlFg};
|
||||
color: ${theme.controlFg};
|
||||
cursor: pointer;
|
||||
line-height: 16px;
|
||||
`);
|
||||
|
||||
|
||||
// TODO: reuse them
|
||||
const cssDragRow = styled('div', `
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
margin: 0 16px 0px 0px;
|
||||
margin-bottom: 2px;
|
||||
cursor: grab;
|
||||
`);
|
||||
|
||||
const cssFieldEntry = styled('div', `
|
||||
display: flex;
|
||||
background-color: ${theme.hover};
|
||||
border-radius: 2px;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 4px 8px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
|
||||
--icon-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssSimpleDragger = styled(cssDragger, `
|
||||
cursor: grab;
|
||||
.${cssDragRow.className}:hover & {
|
||||
visibility: visible;
|
||||
}
|
||||
&-hidden {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHideIcon = styled(icon, `
|
||||
--icon-color: ${theme.lightText};
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
.${cssFieldEntry.className}:hover & {
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFieldLabel = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 auto;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssFieldListHeader = styled('span', `
|
||||
color: ${theme.text};
|
||||
flex: 1 1 0px;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
text-transform: uppercase;
|
||||
`);
|
||||
|
||||
const cssRow = styled('div', `
|
||||
display: flex;
|
||||
margin: 16px;
|
||||
overflow: hidden;
|
||||
--icon-color: ${theme.lightText};
|
||||
& > .${cssButton.className} {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHeader = styled(cssRow, `
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1em;
|
||||
& * {
|
||||
line-height: 1em;
|
||||
}
|
||||
`);
|
||||
@@ -10,11 +10,16 @@ export * from "./Section";
|
||||
export * from './Field';
|
||||
export * from './Columns';
|
||||
export * from './Submit';
|
||||
export * from './Label';
|
||||
|
||||
export function defaultElement(type: BoxType): Box {
|
||||
switch(type) {
|
||||
case 'Columns': return Columns();
|
||||
case 'Placeholder': return Placeholder();
|
||||
case 'Separator': return {
|
||||
type: 'Paragraph',
|
||||
text: '---',
|
||||
};
|
||||
default: return {type};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,177 @@
|
||||
import {textarea} from 'app/client/ui/inputs';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
import {basicButton} from 'app/client/ui2018/buttons';
|
||||
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {BindableValue, dom, IDomArgs, styled, subscribeBindable} from 'grainjs';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
|
||||
import {marked} from 'marked';
|
||||
|
||||
export {
|
||||
cssLabel,
|
||||
cssDesc,
|
||||
cssInput,
|
||||
cssFieldEditor,
|
||||
cssSelectedOverlay,
|
||||
cssControls,
|
||||
cssControlsLabel,
|
||||
cssAddElement,
|
||||
cssAddText,
|
||||
cssFormContainer,
|
||||
cssFormEdit,
|
||||
cssFormEditBody,
|
||||
cssSection,
|
||||
cssStaticText,
|
||||
};
|
||||
|
||||
const cssFormEditBody = styled('div', `
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding-top: 52px;
|
||||
`);
|
||||
|
||||
const cssFormEdit = styled('div', `
|
||||
export const cssFormView = styled('div.flexauto.flexvbox', `
|
||||
color: ${theme.text};
|
||||
background-color: ${theme.leftPanelBg};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-basis: 0px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
background-color: ${theme.leftPanelBg};
|
||||
overflow: auto;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
--section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */
|
||||
&, &-preview {
|
||||
background-color: ${theme.leftPanelBg};
|
||||
overflow: auto;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
flex-basis: 0px;
|
||||
export const cssFormContainer = styled('div', `
|
||||
background-color: ${theme.mainPanelBg};
|
||||
border: 1px solid ${theme.modalBorderDark};
|
||||
color: ${theme.text};
|
||||
width: 600px;
|
||||
align-self: center;
|
||||
margin: 0px auto;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: calc(100% - 32px);
|
||||
padding-top: 20px;
|
||||
padding-left: 48px;
|
||||
padding-right: 48px;
|
||||
gap: 8px;
|
||||
`);
|
||||
|
||||
|
||||
|
||||
export const cssFieldEditor = styled('div.hover_border.field_editor', `
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
--hover-visible: hidden;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
&:hover:not(:has(.hover_border:hover),&-cut) {
|
||||
--hover-visible: visible;
|
||||
outline: 1px solid ${colors.lightGreen};
|
||||
}
|
||||
&-selected:not(&-cut) {
|
||||
background: #F7F7F7;
|
||||
outline: 1px solid ${colors.lightGreen};
|
||||
--selected-block: block;
|
||||
}
|
||||
&:active:not(:has(&:active)) {
|
||||
outline: 1px solid ${colors.darkGreen};
|
||||
}
|
||||
&-drag-hover {
|
||||
outline: 2px dashed ${colors.lightGreen};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
&-cut {
|
||||
outline: 2px dashed ${colors.orange};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
&-FormDescription {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
&-drag-above {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
&-drag-below {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssLabel = styled('label', `
|
||||
export const cssSectionEditor = styled('div', `
|
||||
border-radius: 3px;
|
||||
padding: 16px;
|
||||
border: 1px solid ${theme.modalBorderDark};
|
||||
`);
|
||||
|
||||
|
||||
export const cssSection = styled('div', `
|
||||
position: relative;
|
||||
color: ${theme.text};
|
||||
margin: 0px auto;
|
||||
min-height: 50px;
|
||||
.${cssFormView.className}-preview & {
|
||||
background: transparent;
|
||||
border-radius: unset;
|
||||
padding: 0px;
|
||||
min-height: auto;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssLabel = styled('label', `
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
margin-bottom: 8px;
|
||||
user-select: none;
|
||||
display: block;
|
||||
margin: 0px;
|
||||
`);
|
||||
|
||||
const cssDesc = styled('div', `
|
||||
font-size: 10px;
|
||||
export const cssCheckboxLabel = styled('label', `
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0px;
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
export function textbox(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
|
||||
return dom('input',
|
||||
dom.prop('value', u => u(obs) || ''),
|
||||
dom.on('input', (_e, elem) => obs.set(elem.value)),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
export const cssEditableLabel = styled(textarea, `
|
||||
font-weight: normal;
|
||||
outline: none;
|
||||
display: block;
|
||||
padding: 0px;
|
||||
border: 0px;
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
min-height: 1.5rem;
|
||||
|
||||
color: ${colors.darkText};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
&::placeholder {
|
||||
font-style: italic
|
||||
}
|
||||
&-edit {
|
||||
cursor: auto;
|
||||
background: ${theme.inputBg};
|
||||
outline: 2px solid black;
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
&-normal {
|
||||
color: ${colors.darkText};
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssDesc = styled('div', `
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-top: 4px;
|
||||
color: ${colors.slate};
|
||||
color: ${theme.darkText};
|
||||
white-space: pre-wrap;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
`);
|
||||
|
||||
const cssInput = styled('input', `
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
export const cssInput = styled('input', `
|
||||
font-size: inherit;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #D9D9D9;
|
||||
@@ -77,6 +182,9 @@ const cssInput = styled('input', `
|
||||
&-invalid {
|
||||
color: red;
|
||||
}
|
||||
&[type="number"], &[type="date"], &[type="datetime-local"], &[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssSelect = styled('select', `
|
||||
@@ -94,33 +202,12 @@ export const cssSelect = styled('select', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFieldEditor = styled('div._cssFieldEditor', `
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
&:hover:not(:has(&:hover)), &-selected {
|
||||
outline: 1px solid ${colors.lightGreen};
|
||||
}
|
||||
&:active:not(:has(&:active)) {
|
||||
outline: 1px solid ${colors.darkGreen};
|
||||
}
|
||||
&-drag-hover {
|
||||
outline: 2px dashed ${colors.lightGreen};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
&-cut {
|
||||
outline: 2px dashed ${colors.orange};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.${cssFormEdit.className}-preview & {
|
||||
outline: 0px !import;
|
||||
}
|
||||
export const cssFieldEditorContent = styled('div', `
|
||||
|
||||
`);
|
||||
|
||||
const cssSelectedOverlay = styled('div', `
|
||||
background: ${colors.selection};
|
||||
export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
@@ -129,59 +216,47 @@ const cssSelectedOverlay = styled('div', `
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.${cssFormEdit.className}-preview & {
|
||||
.${cssFormView.className}-preview & {
|
||||
display: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssControls = styled('div', `
|
||||
display: none;
|
||||
position: absolute;
|
||||
margin-top: -18px;
|
||||
margin-left: -1px;
|
||||
.${cssFieldEditor.className}:hover:not(:has(.${cssFieldEditor.className}:hover)) > &,
|
||||
.${cssFieldEditor.className}:active:not(:has(.${cssFieldEditor.className}:active)) > &,
|
||||
.${cssFieldEditor.className}-selected > & {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.${cssFormEdit.className}-preview & {
|
||||
display: none !important;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssControlsLabel = styled('div', `
|
||||
export const cssControlsLabel = styled('div', `
|
||||
background: ${colors.lightGreen};
|
||||
color: ${colors.light};
|
||||
padding: 1px 2px;
|
||||
min-width: 24px;
|
||||
`);
|
||||
|
||||
const cssAddElement = styled('div', `
|
||||
export const cssPlusButton = styled('div', `
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
--icon-color: ${colors.lightGreen};
|
||||
align-self: stretch;
|
||||
border: 2px dashed ${colors.darkGrey};
|
||||
background: ${colors.lightGrey};
|
||||
opacity: 0.7;
|
||||
&:hover {
|
||||
border: 2px dashed ${colors.darkGrey};
|
||||
background: ${colors.lightGrey};
|
||||
opacity: 1;
|
||||
}
|
||||
&-hover {
|
||||
outline: 2px dashed ${colors.lightGreen};
|
||||
outline-offset: 2px;
|
||||
`);
|
||||
|
||||
export const cssCircle = styled('div', `
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: ${colors.lightGreen};
|
||||
color: ${colors.light};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.${cssPlusButton.className}:hover & {
|
||||
background: ${colors.darkGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssAddText = styled('div', `
|
||||
export const cssPlusIcon = styled(icon, `
|
||||
--icon-color: ${colors.light};
|
||||
`);
|
||||
|
||||
export const cssAddText = styled('div', `
|
||||
color: ${colors.slate};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
@@ -190,39 +265,22 @@ const cssAddText = styled('div', `
|
||||
&:before {
|
||||
content: "Add a field";
|
||||
}
|
||||
.${cssAddElement.className}-hover &:before {
|
||||
.${cssPlusButton.className}-hover &:before {
|
||||
content: "Drop here";
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
position: relative;
|
||||
background-color: var(--section-background);
|
||||
color: ${theme.text};
|
||||
align-self: center;
|
||||
margin: 0px auto;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 50px;
|
||||
padding: 10px;
|
||||
.${cssFormEdit.className}-preview & {
|
||||
background: transparent;
|
||||
border-radius: unset;
|
||||
padding: 0px;
|
||||
min-height: auto;
|
||||
}
|
||||
export const cssPadding = styled('div', `
|
||||
`);
|
||||
|
||||
export const cssColumns = styled('div', `
|
||||
--css-columns-count: 2;
|
||||
background-color: var(--section-background);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px;
|
||||
gap: 8px;
|
||||
padding: 12px 4px;
|
||||
|
||||
.${cssFormEdit.className}-preview & {
|
||||
.${cssFormView.className}-preview & {
|
||||
background: transparent;
|
||||
border-radius: unset;
|
||||
padding: 0px;
|
||||
@@ -269,11 +327,11 @@ export const cssColumn = styled('div', `
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.${cssFormEdit.className}-preview &-add-button {
|
||||
.${cssFormView.className}-preview &-add-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.${cssFormEdit.className}-preview &-empty {
|
||||
.${cssFormView.className}-preview &-empty {
|
||||
background: transparent;
|
||||
border-radius: unset;
|
||||
padding: 0px;
|
||||
@@ -282,29 +340,43 @@ export const cssColumn = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFormContainer = styled('div', `
|
||||
padding: 32px;
|
||||
background-color: ${theme.mainPanelBg};
|
||||
border: 1px solid ${theme.menuBorder};
|
||||
color: ${theme.text};
|
||||
width: 640px;
|
||||
align-self: center;
|
||||
margin: 0px auto;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 16px;
|
||||
max-width: calc(100% - 32px);
|
||||
`);
|
||||
|
||||
export const cssButtonGroup = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 0px 24px 0px 24px;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
/* So that the height is 40px in normal state */
|
||||
padding-top: calc((40px - 24px) / 2);
|
||||
padding-bottom: calc((40px - 24px) / 2);
|
||||
`);
|
||||
|
||||
|
||||
export const cssIconLink = styled(basicButtonLink, `
|
||||
padding: 3px 8px;
|
||||
font-size: ${vars.smallFontSize};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 24px;
|
||||
|
||||
&-standard {
|
||||
background-color: ${theme.leftPanelBg};
|
||||
}
|
||||
&-warning {
|
||||
color: ${theme.controlPrimaryFg};
|
||||
background-color: ${theme.toastWarningBg};
|
||||
border: none;
|
||||
}
|
||||
&-warning:hover {
|
||||
color: ${theme.controlPrimaryFg};
|
||||
background-color: #B8791B;
|
||||
border: none;
|
||||
}
|
||||
&-frameless {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssIconButton = styled(basicButton, `
|
||||
@@ -328,21 +400,89 @@ export const cssIconButton = styled(basicButton, `
|
||||
background-color: #B8791B;
|
||||
border: none;
|
||||
}
|
||||
&-frameless {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssStaticText = styled('div', `
|
||||
export const cssMarkdownRendered = styled('div', `
|
||||
min-height: 1.5rem;
|
||||
font-size: 15px;
|
||||
& textarea {
|
||||
font-size: 15px;
|
||||
}
|
||||
& strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
&-alignment-left {
|
||||
text-align: left;
|
||||
}
|
||||
&-alignment-center {
|
||||
text-align: center;
|
||||
}
|
||||
&-alignment-right {
|
||||
text-align: right;
|
||||
}
|
||||
& hr {
|
||||
border-color: ${colors.darkGrey};
|
||||
margin: 8px 0px;
|
||||
}
|
||||
&-separator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
&-separator hr {
|
||||
margin: 0px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssMarkdownRender = styled('div', `
|
||||
& > p:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
& h1 {
|
||||
font-size: 24px;
|
||||
margin: 4px 0px;
|
||||
font-weight: normal;
|
||||
}
|
||||
& h2 {
|
||||
font-size: 22px;
|
||||
margin: 4px 0px;
|
||||
font-weight: normal;
|
||||
}
|
||||
& h3 {
|
||||
font-size: 16px;
|
||||
margin: 4px 0px;
|
||||
font-weight: normal;
|
||||
}
|
||||
& h4 {
|
||||
font-size: 13px;
|
||||
margin: 4px 0px;
|
||||
font-weight: normal;
|
||||
}
|
||||
& h5 {
|
||||
font-size: 11px;
|
||||
margin: 4px 0px;
|
||||
font-weight: normal;
|
||||
}
|
||||
& h6 {
|
||||
font-size: 10px;
|
||||
margin: 4px 0px;
|
||||
font-weight: normal;
|
||||
}
|
||||
`);
|
||||
|
||||
export function markdown(obs: BindableValue<string>, ...args: IDomArgs<HTMLDivElement>) {
|
||||
return dom('div', el => {
|
||||
return cssMarkdownRender(el => {
|
||||
dom.autoDisposeElem(el, subscribeBindable(obs, val => {
|
||||
el.innerHTML = sanitizeHTML(marked(val));
|
||||
}));
|
||||
}, ...args);
|
||||
}
|
||||
|
||||
export const cssDrag = styled('div.test-forms-drag', `
|
||||
export const cssDrop = styled('div.test-forms-drag', `
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 2px;
|
||||
@@ -351,21 +491,45 @@ export const cssDrag = styled('div.test-forms-drag', `
|
||||
height: 1px;
|
||||
`);
|
||||
|
||||
export const cssDragWrapper = styled('div', `
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
left: -16px;
|
||||
top: 0px;
|
||||
height: 100%;
|
||||
width: 16px;
|
||||
`);
|
||||
|
||||
export const cssDrag = styled(icon, `
|
||||
position: absolute;
|
||||
visibility: var(--hover-visible, hidden);
|
||||
top: calc(50% - 16px / 2);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
--icon-color: ${colors.lightGreen};
|
||||
&-top {
|
||||
top: 16px;
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
export const cssPreview = styled('iframe', `
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 0px;
|
||||
`);
|
||||
|
||||
export const cssSwitcher = styled('div', `
|
||||
flex-shrink: 0;
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
border-top: 1px solid ${theme.modalBorder};
|
||||
margin-left: -48px;
|
||||
margin-right: -48px;
|
||||
`);
|
||||
|
||||
export const cssSwitcherMessage = styled('div', `
|
||||
display: flex;
|
||||
padding: 0px 16px 0px 16px;
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
export const cssSwitcherMessageBody = styled('div', `
|
||||
@@ -373,7 +537,7 @@ export const cssSwitcherMessageBody = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0px 32px 0px 32px;
|
||||
padding: 10px 32px;
|
||||
`);
|
||||
|
||||
export const cssSwitcherMessageDismissButton = styled('div', `
|
||||
@@ -392,3 +556,73 @@ export const cssSwitcherMessageDismissButton = styled('div', `
|
||||
export const cssParagraph = styled('div', `
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
export const cssFormEditBody = styled('div', `
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding-top: 52px;
|
||||
padding-bottom: 24px;
|
||||
`);
|
||||
|
||||
export const cssRemoveButton = styled('div', `
|
||||
position: absolute;
|
||||
right: 11px;
|
||||
top: 11px;
|
||||
border-radius: 3px;
|
||||
background: ${colors.darkGrey};
|
||||
display: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0px;
|
||||
z-index: 3;
|
||||
& > div {
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
}
|
||||
&:hover {
|
||||
background: ${colors.mediumGreyOpaque};
|
||||
cursor: pointer;
|
||||
}
|
||||
.${cssFieldEditor.className}-selected > &,
|
||||
.${cssFieldEditor.className}:hover > & {
|
||||
display: flex;
|
||||
}
|
||||
&-right {
|
||||
right: -20px;
|
||||
}
|
||||
`);
|
||||
|
||||
export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {
|
||||
return [
|
||||
dom.onKeyDown({
|
||||
Enter$: (ev) => {
|
||||
// if shift ignore
|
||||
if (ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
save(true);
|
||||
editMode.set(false);
|
||||
if (ev.target && 'blur' in ev.target) {
|
||||
(ev.target as any).blur();
|
||||
}
|
||||
},
|
||||
Escape: (ev) => {
|
||||
save(false);
|
||||
editMode.set(false);
|
||||
if (ev.target && 'blur' in ev.target) {
|
||||
(ev.target as any).blur();
|
||||
}
|
||||
}
|
||||
}),
|
||||
dom.on('blur', (ev) => {
|
||||
if (!editMode.isDisposed() && editMode.get()) {
|
||||
save(true);
|
||||
editMode.set(false);
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {UserError} from 'app/client/models/errors';
|
||||
import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
|
||||
import TableModel from 'app/client/models/TableModel';
|
||||
import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
|
||||
import {App} from 'app/client/ui/App';
|
||||
import {DocHistory} from 'app/client/ui/DocHistory';
|
||||
@@ -45,7 +46,7 @@ import {startDocTour} from "app/client/ui/DocTour";
|
||||
import {DocTutorial} from 'app/client/ui/DocTutorial';
|
||||
import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
|
||||
import {isTourActive} from "app/client/ui/OnBoardingPopups";
|
||||
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||
import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
|
||||
import {WebhookPage} from 'app/client/ui/WebhookPage';
|
||||
import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
|
||||
@@ -71,7 +72,7 @@ import {StringUnion} from 'app/common/StringUnion';
|
||||
import {TableData} from 'app/common/TableData';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {DocStateComparison} from 'app/common/UserAPI';
|
||||
import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes';
|
||||
import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType, WidgetType} from 'app/common/widgetTypes';
|
||||
import {CursorPos, UIRowId} from 'app/plugin/GristAPI';
|
||||
import {
|
||||
bundleChanges,
|
||||
@@ -82,8 +83,10 @@ import {
|
||||
fromKo,
|
||||
Holder,
|
||||
IDisposable,
|
||||
IDisposableOwner,
|
||||
IDomComponent,
|
||||
keyframes,
|
||||
MultiHolder,
|
||||
Observable,
|
||||
styled,
|
||||
subscribe,
|
||||
@@ -474,6 +477,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
|
||||
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
|
||||
setCursor: this.onSetCursorPos.bind(this),
|
||||
createForm: this.onCreateForm.bind(this),
|
||||
}, this, true));
|
||||
|
||||
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
|
||||
@@ -873,7 +877,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await docData.bundleActions(
|
||||
const res: {sectionRef: number} = await docData.bundleActions(
|
||||
t("Added new linked section to view {{viewName}}", {viewName}),
|
||||
() => this.addWidgetToPageImpl(val, tableId ?? null)
|
||||
);
|
||||
@@ -886,6 +890,21 @@ export class GristDoc extends DisposableWithEvents {
|
||||
if (AttachedCustomWidgets.guard(val.type)) {
|
||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
||||
}
|
||||
|
||||
return res.sectionRef;
|
||||
}
|
||||
|
||||
public async onCreateForm() {
|
||||
const table = this.currentView.get()?.viewSection.tableRef.peek();
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
await this.addWidgetToPage({
|
||||
...DefaultPageWidget(),
|
||||
table,
|
||||
type: WidgetType.Form,
|
||||
});
|
||||
commands.allCommands.expandSection.run();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -914,7 +933,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
return;
|
||||
}
|
||||
let newViewId: IDocPage;
|
||||
if (val.type === 'record') {
|
||||
if (val.type === WidgetType.Table) {
|
||||
const result = await this.docData.sendAction(['AddEmptyTable', name]);
|
||||
newViewId = result.views[0].id;
|
||||
} else {
|
||||
@@ -1468,6 +1487,32 @@ export class GristDoc extends DisposableWithEvents {
|
||||
this._showBackgroundVideoPlayer.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates computed with all the data for the given column.
|
||||
*/
|
||||
public columnObserver(owner: IDisposableOwner, tableId: Observable<string>, columnId: Observable<string>) {
|
||||
const tableModel = Computed.create(owner, (use) => this.docModel.dataTables[use(tableId)]);
|
||||
const refreshed = Observable.create(owner, 0);
|
||||
const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);
|
||||
const holder = Holder.create(owner);
|
||||
const listener = (tab: TableModel) => {
|
||||
// Now subscribe to any data change in that table.
|
||||
const subs = MultiHolder.create(holder);
|
||||
subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle));
|
||||
subs.autoDispose(tab.tableData.tableActionEmitter.addListener(toggle));
|
||||
tab.fetch().catch(reportError);
|
||||
};
|
||||
owner.autoDispose(tableModel.addListener(listener));
|
||||
listener(tableModel.get());
|
||||
const values = Computed.create(owner, refreshed, (use) => {
|
||||
const rows = use(tableModel).getAllRows();
|
||||
const colValues = use(tableModel).tableData.getColValues(use(columnId));
|
||||
if (!colValues) { return []; }
|
||||
return rows.map((row, i) => [row, colValues[i]]);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
private _focusPreviousSection() {
|
||||
const prevSectionId = this._prevSectionId;
|
||||
if (!prevSectionId) { return; }
|
||||
|
||||
@@ -197,7 +197,7 @@ export class LayoutTray extends DisposableWithEvents {
|
||||
// And ask the viewLayout to save the specs.
|
||||
viewLayout.saveLayoutSpec();
|
||||
},
|
||||
expandSection: () => {
|
||||
restoreSection: () => {
|
||||
// Get the section that is collapsed and clicked (we are setting this value).
|
||||
const leafId = viewLayout.viewModel.activeCollapsedSectionId();
|
||||
if (!leafId) { return; }
|
||||
|
||||
@@ -147,21 +147,19 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() {
|
||||
};
|
||||
|
||||
ViewConfigTab.prototype._buildThemeDom = function() {
|
||||
return kd.maybe(this.activeSectionData, (sectionData) => {
|
||||
var section = sectionData.section;
|
||||
if (this.isDetail()) {
|
||||
const theme = Computed.create(null, (use) => use(section.themeDef));
|
||||
theme.onWrite(val => section.themeDef.setAndSave(val));
|
||||
return cssRow(
|
||||
dom.autoDispose(theme),
|
||||
select(theme, [
|
||||
{label: t("Form"), value: 'form' },
|
||||
{label: t("Compact"), value: 'compact'},
|
||||
{label: t("Blocks"), value: 'blocks' },
|
||||
]),
|
||||
testId('detail-theme')
|
||||
);
|
||||
}
|
||||
return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => {
|
||||
const section = sectionData.section;
|
||||
const theme = Computed.create(null, (use) => use(section.themeDef));
|
||||
theme.onWrite(val => section.themeDef.setAndSave(val));
|
||||
return cssRow(
|
||||
dom.autoDispose(theme),
|
||||
select(theme, [
|
||||
{label: t("Form"), value: 'form' },
|
||||
{label: t("Compact"), value: 'compact'},
|
||||
{label: t("Blocks"), value: 'blocks' },
|
||||
]),
|
||||
testId('detail-theme')
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -170,21 +168,19 @@ ViewConfigTab.prototype._buildChartConfigDom = function() {
|
||||
};
|
||||
|
||||
ViewConfigTab.prototype._buildLayoutDom = function() {
|
||||
return kd.maybe(this.activeSectionData, (sectionData) => {
|
||||
if (this.isDetail()) {
|
||||
const view = sectionData.section.viewInstance.peek();
|
||||
const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor());
|
||||
return cssRow({style: 'margin-top: 16px;'},
|
||||
kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()),
|
||||
primaryButton(t("Edit Card Layout"),
|
||||
dom.autoDispose(layoutEditorObs),
|
||||
dom.on('click', () => commands.allCommands.editLayout.run()),
|
||||
grainjsDom.hide(layoutEditorObs),
|
||||
grainjsDom.cls('behavioral-prompt-edit-card-layout'),
|
||||
testId('detail-edit-layout'),
|
||||
)
|
||||
);
|
||||
}
|
||||
return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => {
|
||||
const view = sectionData.section.viewInstance.peek();
|
||||
const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor());
|
||||
return cssRow({style: 'margin-top: 16px;'},
|
||||
kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()),
|
||||
primaryButton(t("Edit Card Layout"),
|
||||
dom.autoDispose(layoutEditorObs),
|
||||
dom.on('click', () => commands.allCommands.editLayout.run()),
|
||||
grainjsDom.hide(layoutEditorObs),
|
||||
grainjsDom.cls('behavioral-prompt-edit-card-layout'),
|
||||
testId('detail-edit-layout'),
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
prevSection: () => { this._otherSection(-1); },
|
||||
printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
|
||||
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
|
||||
maximizeActiveSection: () => { this._maximizeActiveSection(); },
|
||||
expandSection: () => { this._expandSection(); },
|
||||
cancel: () => {
|
||||
if (this.maximized.get()) {
|
||||
this.maximized.set(null);
|
||||
@@ -294,7 +294,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
oldTray.dispose();
|
||||
}
|
||||
|
||||
private _maximizeActiveSection() {
|
||||
private _expandSection() {
|
||||
const activeSection = this.viewModel.activeSection();
|
||||
const activeSectionId = activeSection.getRowId();
|
||||
const activeSectionBox = this.layout.getLeafBox(activeSectionId);
|
||||
|
||||
@@ -22,7 +22,7 @@ export type CommandName =
|
||||
| 'printSection'
|
||||
| 'showRawData'
|
||||
| 'openWidgetConfiguration'
|
||||
| 'maximizeActiveSection'
|
||||
| 'expandSection'
|
||||
| 'leftPanelOpen'
|
||||
| 'rightPanelOpen'
|
||||
| 'videoTourToolsOpen'
|
||||
@@ -95,7 +95,7 @@ export type CommandName =
|
||||
| 'addSection'
|
||||
| 'deleteSection'
|
||||
| 'collapseSection'
|
||||
| 'expandSection'
|
||||
| 'restoreSection'
|
||||
| 'deleteCollapsedSection'
|
||||
| 'duplicateRows'
|
||||
| 'sortAsc'
|
||||
@@ -115,6 +115,8 @@ export type CommandName =
|
||||
| 'activateAssistant'
|
||||
| 'viewAsCard'
|
||||
| 'showColumns'
|
||||
| 'createForm'
|
||||
| 'insertField'
|
||||
;
|
||||
|
||||
|
||||
@@ -252,7 +254,7 @@ export const groups: CommendGroupDef[] = [{
|
||||
desc: 'Open Custom widget configuration screen',
|
||||
},
|
||||
{
|
||||
name: 'maximizeActiveSection',
|
||||
name: 'expandSection',
|
||||
keys: [],
|
||||
desc: 'Maximize the active section',
|
||||
},
|
||||
@@ -281,6 +283,16 @@ export const groups: CommendGroupDef[] = [{
|
||||
keys: ['Space'],
|
||||
desc: 'Show the record card widget of the selected record',
|
||||
},
|
||||
{
|
||||
name: 'createForm',
|
||||
keys: [],
|
||||
desc: 'Creates form for active table',
|
||||
},
|
||||
{
|
||||
name: 'insertField',
|
||||
keys: [],
|
||||
desc: 'Insert new column in default location',
|
||||
},
|
||||
]
|
||||
}, {
|
||||
group: 'Navigation',
|
||||
@@ -590,7 +602,7 @@ export const groups: CommendGroupDef[] = [{
|
||||
keys: [],
|
||||
desc: 'Collapse the currently active viewsection'
|
||||
}, {
|
||||
name: 'expandSection',
|
||||
name: 'restoreSection',
|
||||
keys: [],
|
||||
desc: 'Expand collapsed viewsection'
|
||||
}, {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {UserAction} from 'app/common/DocActions';
|
||||
import {RecalcWhen} from 'app/common/gristTypes';
|
||||
import {arrayRepeat} from 'app/common/gutil';
|
||||
import {Sort} from 'app/common/SortSpec';
|
||||
import {WidgetType} from 'app/common/widgetTypes';
|
||||
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
|
||||
import {CursorPos, UIRowId} from 'app/plugin/GristAPI';
|
||||
import {GristObjCode} from 'app/plugin/GristData';
|
||||
@@ -259,6 +260,8 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
||||
// Common type of selected columns or mixed.
|
||||
columnsType: ko.PureComputed<string|'mixed'>;
|
||||
|
||||
widgetType: modelUtil.KoSaveableObservable<WidgetType>;
|
||||
|
||||
// Save all filters of fields/columns in the section.
|
||||
saveFilters(): Promise<void>;
|
||||
|
||||
@@ -276,9 +279,19 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
||||
|
||||
insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewFieldInfo>;
|
||||
|
||||
/**
|
||||
* Shows column (by adding a view field)
|
||||
* @param col ColId or ColRef
|
||||
* @param index Position to insert the column at
|
||||
* @returns ViewField rowId
|
||||
*/
|
||||
showColumn(col: number|string, index?: number): Promise<number>
|
||||
|
||||
removeField(colRef: number): Promise<void>;
|
||||
/**
|
||||
* Removes one or multiple fields.
|
||||
* @param colRef
|
||||
*/
|
||||
removeField(colRef: number|Array<number>): Promise<void>;
|
||||
}
|
||||
|
||||
export type WidgetMappedColumn = number|number[]|null;
|
||||
@@ -361,6 +374,7 @@ export interface Filter {
|
||||
}
|
||||
|
||||
export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void {
|
||||
this.widgetType = this.parentKey as any;
|
||||
this.viewFields = recordSet(this, docModel.viewFields, 'parentId', {sortBy: 'parentPos'});
|
||||
this.linkedSections = recordSet(this, docModel.viewSections, 'linkSrcSectionRef');
|
||||
|
||||
@@ -872,8 +886,13 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
return await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]);
|
||||
};
|
||||
|
||||
this.removeField = async (fieldRef: number) => {
|
||||
const action = ['RemoveRecord', fieldRef];
|
||||
await docModel.viewFields.sendTableAction(action);
|
||||
this.removeField = async (fieldRef: number|number[]) => {
|
||||
if (Array.isArray(fieldRef)) {
|
||||
const action = ['BulkRemoveRecord', fieldRef];
|
||||
await docModel.viewFields.sendTableAction(action);
|
||||
} else {
|
||||
const action = ['RemoveRecord', fieldRef];
|
||||
await docModel.viewFields.sendTableAction(action);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,18 +52,20 @@ export function buildDescriptionConfig(
|
||||
export function buildTextInput(
|
||||
owner: MultiHolder,
|
||||
options: {
|
||||
value: KoSaveableObservable<any>,
|
||||
cursor: ko.Computed<CursorPos>,
|
||||
label: string,
|
||||
value: KoSaveableObservable<any>,
|
||||
cursor?: ko.Computed<CursorPos>,
|
||||
placeholder?: ko.Computed<string>,
|
||||
},
|
||||
...args: DomArg[]
|
||||
) {
|
||||
owner.autoDispose(
|
||||
options.cursor.subscribe(() => {
|
||||
options.value.save().catch(reportError);
|
||||
})
|
||||
);
|
||||
if (options.cursor) {
|
||||
owner.autoDispose(
|
||||
options.cursor.subscribe(() => {
|
||||
options.value.save().catch(reportError);
|
||||
})
|
||||
);
|
||||
}
|
||||
return [
|
||||
cssLabel(options.label),
|
||||
cssRow(
|
||||
@@ -84,7 +86,6 @@ const cssTextInput = styled(textInput, `
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border-radius: 3px;
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
padding: 0px 6px;
|
||||
|
||||
@@ -59,6 +59,15 @@ export interface IPageWidget {
|
||||
section: number;
|
||||
}
|
||||
|
||||
export const DefaultPageWidget: () => IPageWidget = () => ({
|
||||
type: 'record',
|
||||
table: null,
|
||||
summarize: false,
|
||||
columns: [],
|
||||
link: NoLink,
|
||||
section: 0,
|
||||
});
|
||||
|
||||
// Creates a IPageWidget from a ViewSectionRec.
|
||||
export function toPageWidget(section: ViewSectionRec): IPageWidget {
|
||||
const link = linkId({
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
*/
|
||||
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {HiddenQuestionConfig} from 'app/client/components/Forms/HiddenQuestionConfig';
|
||||
import {FieldModel} from 'app/client/components/Forms/Field';
|
||||
import {FormView} from 'app/client/components/Forms/FormView';
|
||||
import {UnmappedFieldsConfig} from 'app/client/components/Forms/UnmappedFieldsConfig';
|
||||
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
||||
import {EmptyFilterState} from "app/client/components/LinkingState";
|
||||
import {RefSelect} from 'app/client/components/RefSelect';
|
||||
@@ -27,9 +29,11 @@ import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||
import {buildDescriptionConfig, buildTextInput} from 'app/client/ui/DescriptionConfig';
|
||||
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
||||
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
||||
import {autoGrow} from 'app/client/ui/forms';
|
||||
import {GridOptions} from 'app/client/ui/GridOptions';
|
||||
import {textarea} from 'app/client/ui/inputs';
|
||||
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||
import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig";
|
||||
import {cssLabel} from 'app/client/ui/RightPanelStyles';
|
||||
@@ -37,6 +41,8 @@ import {linkId, selectBy} from 'app/client/ui/selectBy';
|
||||
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
|
||||
import {widgetTypesMap} from "app/client/ui/widgetTypesMap";
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
@@ -56,6 +62,7 @@ import {
|
||||
DomContents,
|
||||
DomElementArg,
|
||||
DomElementMethod,
|
||||
fromKo,
|
||||
IDomComponent,
|
||||
MultiHolder,
|
||||
Observable,
|
||||
@@ -74,7 +81,7 @@ const t = makeT('RightPanel');
|
||||
const TopTab = StringUnion("pageWidget", "field");
|
||||
|
||||
// Represents a subtab of pageWidget in the right side-pane.
|
||||
const PageSubTab = StringUnion("widget", "sortAndFilter", "data");
|
||||
const PageSubTab = StringUnion("widget", "sortAndFilter", "data", "submission");
|
||||
|
||||
// Returns the icon and label of a type, default to those associate to 'record' type.
|
||||
export function getFieldType(widgetType: IWidgetType|null) {
|
||||
@@ -85,6 +92,7 @@ export function getFieldType(widgetType: IWidgetType|null) {
|
||||
['single', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}],
|
||||
['chart', {label: t('Series', { count: 1 }), icon: 'ChartLine', pluralLabel: t('Series', { count: 2 })}],
|
||||
['custom', {label: t('Columns', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Columns', { count: 2 })}],
|
||||
['form', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}],
|
||||
]);
|
||||
|
||||
return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!;
|
||||
@@ -111,6 +119,10 @@ export class RightPanel extends Disposable {
|
||||
return (use(section.parentKey) || null) as IWidgetType;
|
||||
});
|
||||
|
||||
private _isForm = Computed.create(this, (use) => {
|
||||
return use(this._pageWidgetType) === 'form';
|
||||
});
|
||||
|
||||
// Returns the active section if it's valid, null otherwise.
|
||||
private _validSection = Computed.create(this, (use) => {
|
||||
const sec = use(this._gristDoc.viewModel.activeSection);
|
||||
@@ -135,6 +147,16 @@ export class RightPanel extends Disposable {
|
||||
sortFilterTabOpen: () => this._openSortFilter(),
|
||||
dataSelectionTabOpen: () => this._openDataSelection()
|
||||
}, this, true));
|
||||
|
||||
// When a page widget is changed, subType might not be valid anymore, so reset it.
|
||||
// TODO: refactor sub tabs and navigation using order of the tab.
|
||||
this.autoDispose(subscribe((use) => {
|
||||
if (!use(this._isForm) && use(this._subTab) === 'submission') {
|
||||
setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('sortAndFilter'));
|
||||
} else if (use(this._isForm) && use(this._subTab) === 'sortAndFilter') {
|
||||
setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('submission'));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private _openFieldTab() {
|
||||
@@ -216,13 +238,27 @@ export class RightPanel extends Disposable {
|
||||
if (!use(this._isOpen)) { return null; }
|
||||
const tool = use(this._extraTool);
|
||||
if (tool) { return tabContentToDom(tool.content); }
|
||||
const isForm = use(this._isForm);
|
||||
|
||||
const topTab = use(this._topTab);
|
||||
if (topTab === 'field') {
|
||||
return dom.create(this._buildFieldContent.bind(this));
|
||||
}
|
||||
if (topTab === 'pageWidget' && use(this._pageWidgetType)) {
|
||||
return dom.create(this._buildPageWidgetContent.bind(this));
|
||||
if (isForm) {
|
||||
return dom.create(this._buildQuestionContent.bind(this));
|
||||
} else {
|
||||
return dom.create(this._buildFieldContent.bind(this));
|
||||
}
|
||||
} else if (topTab === 'pageWidget') {
|
||||
if (isForm) {
|
||||
return [
|
||||
dom.create(this._buildPageFormHeader.bind(this)),
|
||||
dom.create(this._buildPageWidgetContent.bind(this)),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
dom.create(this._buildPageWidgetHeader.bind(this)),
|
||||
dom.create(this._buildPageWidgetContent.bind(this)),
|
||||
];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -264,18 +300,6 @@ export class RightPanel extends Disposable {
|
||||
// Builder for the reference display column multiselect.
|
||||
const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder});
|
||||
|
||||
// The original selected field model.
|
||||
const fieldRef = owner.autoDispose(ko.pureComputed(() => {
|
||||
return ((fieldBuilder()?.field)?.id()) ?? 0;
|
||||
}));
|
||||
const selectedField = owner.autoDispose(docModel.viewFields.createFloatingRowModel(fieldRef));
|
||||
|
||||
// For forms we will show some extra options.
|
||||
const isForm = owner.autoDispose(ko.computed(() => {
|
||||
const vs = this._gristDoc.viewModel.activeSection();
|
||||
return vs.parentKey() === 'form';
|
||||
}));
|
||||
|
||||
// build cursor position observable
|
||||
const cursor = owner.autoDispose(ko.computed(() => {
|
||||
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
||||
@@ -289,14 +313,6 @@ export class RightPanel extends Disposable {
|
||||
cssSection(
|
||||
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
|
||||
),
|
||||
dom.maybe(isForm, () => [
|
||||
cssSection(
|
||||
dom.create(buildTextInput, {
|
||||
cursor, label: 'Question', value: selectedField.question,
|
||||
placeholder: selectedField.origLabel
|
||||
}),
|
||||
),
|
||||
]),
|
||||
cssSection(
|
||||
dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }),
|
||||
),
|
||||
@@ -357,7 +373,48 @@ export class RightPanel extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
private _buildPageWidgetContent(_owner: MultiHolder) {
|
||||
private _buildPageWidgetContent() {
|
||||
const content = (activeSection: ViewSectionRec, type: typeof PageSubTab.type) => {
|
||||
switch(type){
|
||||
case 'widget': return dom.create(this._buildPageWidgetConfig.bind(this), activeSection);
|
||||
case 'sortAndFilter': return [
|
||||
dom.create(this._buildPageSortFilterConfig.bind(this)),
|
||||
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
|
||||
];
|
||||
case 'data': return dom.create(this._buildPageDataConfig.bind(this), activeSection);
|
||||
case 'submission': return dom.create(this._buildPageSubmissionConfig.bind(this), activeSection);
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
return dom.domComputed(this._subTab, (subTab) => (
|
||||
dom.maybe(this._validSection, (activeSection) => (
|
||||
buildConfigContainer(
|
||||
content(activeSection, subTab)
|
||||
)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
private _buildPageFormHeader(_owner: MultiHolder) {
|
||||
return [
|
||||
cssSubTabContainer(
|
||||
cssSubTab(t("Configuration"),
|
||||
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'),
|
||||
dom.on('click', () => this._subTab.set("widget")),
|
||||
testId('config-widget')),
|
||||
cssSubTab(t("Submission"),
|
||||
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'submission'),
|
||||
dom.on('click', () => this._subTab.set("submission")),
|
||||
testId('config-submission')),
|
||||
cssSubTab(t("Data"),
|
||||
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'),
|
||||
dom.on('click', () => this._subTab.set("data")),
|
||||
testId('config-data')),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildPageWidgetHeader(_owner: MultiHolder) {
|
||||
return [
|
||||
cssSubTabContainer(
|
||||
cssSubTab(t("Widget"),
|
||||
@@ -373,19 +430,6 @@ export class RightPanel extends Disposable {
|
||||
dom.on('click', () => this._subTab.set("data")),
|
||||
testId('config-data')),
|
||||
),
|
||||
dom.domComputed(this._subTab, (subTab) => (
|
||||
dom.maybe(this._validSection, (activeSection) => (
|
||||
buildConfigContainer(
|
||||
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
|
||||
subTab === 'sortAndFilter' ? [
|
||||
dom.create(this._buildPageSortFilterConfig.bind(this)),
|
||||
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
|
||||
] :
|
||||
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
|
||||
null
|
||||
)
|
||||
))
|
||||
))
|
||||
];
|
||||
}
|
||||
|
||||
@@ -449,21 +493,6 @@ export class RightPanel extends Disposable {
|
||||
),
|
||||
),
|
||||
|
||||
cssSeparator(dom.hide(activeSection.isRecordCard)),
|
||||
|
||||
dom.domComputed(use => {
|
||||
const vs = use(activeSection.viewInstance);
|
||||
if (!vs || use(activeSection.parentKey) !== 'form') { return null; }
|
||||
return [
|
||||
cssRow(
|
||||
primaryButton(t("Reset form"), dom.on('click', () => {
|
||||
activeSection.layoutSpecObj.setAndSave(null).catch(reportError);
|
||||
})),
|
||||
cssRow.cls('-top-space')
|
||||
),
|
||||
];
|
||||
}),
|
||||
|
||||
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
|
||||
cssLabel(t("Theme")),
|
||||
dom('div',
|
||||
@@ -526,9 +555,9 @@ export class RightPanel extends Disposable {
|
||||
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
|
||||
]),
|
||||
|
||||
dom.maybe(use => use(activeSection.parentKey) === 'form', () => [
|
||||
dom.maybe(this._isForm, () => [
|
||||
cssSeparator(),
|
||||
dom.create(HiddenQuestionConfig, activeSection),
|
||||
dom.create(UnmappedFieldsConfig, activeSection),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -733,10 +762,6 @@ export class RightPanel extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
|
||||
const viewConfigTab = this._createViewConfigTab(owner);
|
||||
const viewModel = this._gristDoc.viewModel;
|
||||
@@ -874,6 +899,180 @@ export class RightPanel extends Disposable {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private _buildPageSubmissionConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
|
||||
// All of those observables are backed by the layout config.
|
||||
const submitButtonKo = activeSection.layoutSpecObj.prop('submitText');
|
||||
const toComputed = (obs: typeof submitButtonKo) => {
|
||||
const result = Computed.create(owner, (use) => use(obs));
|
||||
result.onWrite(val => obs.setAndSave(val));
|
||||
return result;
|
||||
};
|
||||
const submitButton = toComputed(submitButtonKo);
|
||||
const successText = toComputed(activeSection.layoutSpecObj.prop('successText'));
|
||||
const successURL = toComputed(activeSection.layoutSpecObj.prop('successURL'));
|
||||
const anotherResponse = toComputed(activeSection.layoutSpecObj.prop('anotherResponse'));
|
||||
const redirection = Observable.create(owner, Boolean(successURL.get()));
|
||||
owner.autoDispose(redirection.addListener(val => {
|
||||
if (!val) {
|
||||
successURL.set(null);
|
||||
}
|
||||
}));
|
||||
owner.autoDispose(successURL.addListener(val => {
|
||||
if (val) {
|
||||
redirection.set(true);
|
||||
}
|
||||
}));
|
||||
return [
|
||||
cssLabel(t("Submit button label")),
|
||||
cssRow(
|
||||
cssTextInput(submitButton, (val) => submitButton.set(val)),
|
||||
),
|
||||
cssLabel(t("Success text")),
|
||||
cssRow(
|
||||
cssTextArea(successText, {onInput: true}, autoGrow(successText)),
|
||||
),
|
||||
cssLabel(t("Submit another response")),
|
||||
cssRow(
|
||||
labeledSquareCheckbox(anotherResponse, [
|
||||
t("Display button"),
|
||||
]),
|
||||
),
|
||||
cssLabel(t("Redirection")),
|
||||
cssRow(
|
||||
labeledSquareCheckbox(redirection, t('Redirect automatically after submission')),
|
||||
),
|
||||
cssRow(
|
||||
cssTextInput(successURL, (val) => successURL.set(val)),
|
||||
dom.show(redirection),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildQuestionContent(owner: MultiHolder) {
|
||||
const fieldBuilder = owner.autoDispose(ko.computed(() => {
|
||||
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
||||
return vsi && vsi.activeFieldBuilder();
|
||||
}));
|
||||
|
||||
const formView = owner.autoDispose(ko.computed(() => {
|
||||
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
||||
return (vsi ?? null) as FormView|null;
|
||||
}));
|
||||
|
||||
const selectedBox = Computed.create(owner, (use) => use(formView) && use(use(formView)!.selectedBox));
|
||||
const selectedField = Computed.create(owner, (use) => {
|
||||
const box = use(selectedBox);
|
||||
if (!box) { return null; }
|
||||
if (box.type !== 'Field') { return null; }
|
||||
const fieldBox = box as FieldModel;
|
||||
return use(fieldBox.field);
|
||||
});
|
||||
const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol));
|
||||
|
||||
const hasText = Computed.create(owner, (use) => {
|
||||
const box = use(selectedBox);
|
||||
if (!box) { return false; }
|
||||
switch (box.type) {
|
||||
case 'Submit':
|
||||
case 'Paragraph':
|
||||
case 'Label':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return cssSection(
|
||||
// Field config.
|
||||
dom.maybe(selectedField, (field) => {
|
||||
const requiredField = field.widgetOptionsJson.prop('formRequired');
|
||||
// V2 thing.
|
||||
// const hiddenField = field.widgetOptionsJson.prop('formHidden');
|
||||
const defaultField = field.widgetOptionsJson.prop('formDefault');
|
||||
const toComputed = (obs: typeof defaultField) => {
|
||||
const result = Computed.create(null, (use) => use(obs));
|
||||
result.onWrite(val => obs.setAndSave(val));
|
||||
return result;
|
||||
};
|
||||
return [
|
||||
cssLabel(t("Field title")),
|
||||
cssRow(
|
||||
cssTextInput(
|
||||
fromKo(field.label),
|
||||
(val) => field.displayLabel.saveOnly(val),
|
||||
dom.prop('readonly', use => use(field.disableModify)),
|
||||
),
|
||||
),
|
||||
cssLabel(t("Table column name")),
|
||||
cssRow(
|
||||
cssTextInput(
|
||||
fromKo(field.colId),
|
||||
(val) => field.column().colId.saveOnly(val),
|
||||
dom.prop('readonly', use => use(field.disableModify)),
|
||||
),
|
||||
),
|
||||
// TODO: this is for V1 as it requires full cell editor here.
|
||||
// cssLabel(t("Default field value")),
|
||||
// cssRow(
|
||||
// cssTextInput(
|
||||
// fromKo(defaultField),
|
||||
// (val) => defaultField.setAndSave(val),
|
||||
// ),
|
||||
// ),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
|
||||
cssSeparator(),
|
||||
cssLabel(t("COLUMN TYPE")),
|
||||
cssSection(
|
||||
builder.buildSelectTypeDom(),
|
||||
),
|
||||
// V2 thing
|
||||
// cssSection(
|
||||
// builder.buildSelectWidgetDom(),
|
||||
// ),
|
||||
dom.maybe(use => ['Choice', 'ChoiceList', 'Ref', 'RefList'].includes(use(builder.field.pureType)), () => [
|
||||
cssSection(
|
||||
builder.buildConfigDom(),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
cssSeparator(),
|
||||
cssLabel(t("Field rules")),
|
||||
cssRow(labeledSquareCheckbox(toComputed(requiredField), t("Required field")),),
|
||||
// V2 thing
|
||||
// cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),),
|
||||
];
|
||||
}),
|
||||
|
||||
// Box config
|
||||
dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [
|
||||
cssLabel(dom.text(box.type)),
|
||||
dom.maybe(hasText, () => [
|
||||
cssRow(
|
||||
cssTextArea(
|
||||
box.prop('text'),
|
||||
{onInput: true, autoGrow: true},
|
||||
dom.on('blur', () => box.save().catch(reportError)),
|
||||
{placeholder: t('Enter text')},
|
||||
),
|
||||
),
|
||||
cssRow(
|
||||
buttonSelect(box.prop('alignment'), [
|
||||
{value: 'left', icon: 'LeftAlign'},
|
||||
{value: 'center', icon: 'CenterAlign'},
|
||||
{value: 'right', icon: 'RightAlign'}
|
||||
]),
|
||||
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
|
||||
)
|
||||
]),
|
||||
]),
|
||||
|
||||
// Default.
|
||||
dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
|
||||
cssLabel(t('Layout')),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function disabledSection() {
|
||||
@@ -1115,6 +1314,27 @@ const cssListItem = styled('li', `
|
||||
padding: 4px 8px;
|
||||
`);
|
||||
|
||||
const cssTextArea = styled(textarea, `
|
||||
flex: 1 0 auto;
|
||||
color: ${theme.inputFg};
|
||||
background-color: ${theme.inputBg};
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: 3px;
|
||||
|
||||
outline: none;
|
||||
padding: 3px 7px;
|
||||
/* Make space at least for two lines: size of line * 2 * line height + 2 * padding + border * 2 */
|
||||
min-height: calc(2em * 1.5 + 2 * 3px + 2px);
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
|
||||
&:disabled {
|
||||
color: ${theme.inputDisabledFg};
|
||||
background-color: ${theme.inputDisabledBg};
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssTextInput = styled(textInput, `
|
||||
flex: 1 0 auto;
|
||||
color: ${theme.inputFg};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {dom, UseCB} from 'grainjs';
|
||||
import {WidgetType} from 'app/common/widgetTypes';
|
||||
|
||||
const t = makeT('ViewLayoutMenu');
|
||||
|
||||
@@ -63,8 +64,11 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
|
||||
;
|
||||
};
|
||||
|
||||
const isCard = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Card;
|
||||
const isTable = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Table;
|
||||
|
||||
return [
|
||||
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
|
||||
dom.maybe(isCard, () => contextMenu),
|
||||
dom.maybe(showRawData,
|
||||
() => menuItemLink(
|
||||
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
|
||||
@@ -91,6 +95,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
|
||||
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
|
||||
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
|
||||
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
|
||||
menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)),
|
||||
]),
|
||||
|
||||
menuDivider(dom.hide(viewSection.isRecordCard)),
|
||||
@@ -133,7 +138,7 @@ export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: G
|
||||
)
|
||||
),
|
||||
menuDivider(),
|
||||
menuItemCmd(allCommands.expandSection, t("Add to page"),
|
||||
menuItemCmd(allCommands.restoreSection, t("Add to page"),
|
||||
dom.cls('disabled', isReadonly),
|
||||
testId('section-expand')),
|
||||
menuItemCmd(allCommands.deleteCollapsedSection, t("Delete widget"),
|
||||
|
||||
@@ -160,7 +160,7 @@ export function viewSectionMenu(
|
||||
cssExpandIconWrapper(
|
||||
cssSmallIcon('Grow'),
|
||||
testId('expandSection'),
|
||||
dom.on('click', () => allCommands.maximizeActiveSection.run()),
|
||||
dom.on('click', () => allCommands.expandSection.run()),
|
||||
hoverTooltip('Expand section', {key: 'expandSection'}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -84,9 +84,20 @@ function resize(el: HTMLTextAreaElement) {
|
||||
}
|
||||
|
||||
export function autoGrow(text: Observable<string>) {
|
||||
// If this should autogrow we need to monitor width of this element.
|
||||
return (el: HTMLTextAreaElement) => {
|
||||
let width = 0;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const elem = entries[0].target as HTMLTextAreaElement;
|
||||
if (elem.offsetWidth !== width && width) {
|
||||
resize(elem);
|
||||
}
|
||||
width = elem.offsetWidth;
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
dom.onDisposeElem(el, () => resizeObserver.disconnect());
|
||||
el.addEventListener('input', () => resize(el));
|
||||
dom.autoDisposeElem(el, text.addListener(() => resize(el)));
|
||||
dom.autoDisposeElem(el, text.addListener(() => setImmediate(() => resize(el))));
|
||||
setTimeout(() => resize(el), 10);
|
||||
dom.autoDisposeElem(el, text.addListener(val => {
|
||||
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {autoGrow} from 'app/client/ui/forms';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {dom, DomElementArg, IDomArgs, IInputOptions, Observable, styled, subscribe} from 'grainjs';
|
||||
|
||||
@@ -47,24 +48,50 @@ export function textInput(obs: Observable<string|undefined>, ...args: DomElement
|
||||
);
|
||||
}
|
||||
|
||||
export interface ITextAreaOptions extends IInputOptions {
|
||||
autoGrow?: boolean;
|
||||
save?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function textarea(
|
||||
obs: Observable<string>, options: IInputOptions, ...args: IDomArgs<HTMLTextAreaElement>
|
||||
obs: Observable<string>, options?: ITextAreaOptions|null, ...args: IDomArgs<HTMLTextAreaElement>
|
||||
): HTMLTextAreaElement {
|
||||
|
||||
const isValid = options.isValid;
|
||||
const isValid = options?.isValid;
|
||||
|
||||
function setValue(elem: HTMLTextAreaElement) {
|
||||
obs.set(elem.value);
|
||||
if (options?.save) { options.save(elem.value); }
|
||||
else { obs.set(elem.value); }
|
||||
if (isValid) { isValid.set(elem.validity.valid); }
|
||||
}
|
||||
|
||||
const value = options?.autoGrow ? Observable.create(null, obs.get()) : null;
|
||||
const trackInput = Boolean(options?.onInput || options?.autoGrow);
|
||||
const onInput = trackInput ? dom.on('input', (e, elem: HTMLTextAreaElement) => {
|
||||
if (options?.onInput) {
|
||||
setValue(elem);
|
||||
}
|
||||
if (options?.autoGrow) {
|
||||
value?.set(elem.value);
|
||||
}
|
||||
}) : null;
|
||||
|
||||
|
||||
return dom('textarea', ...args,
|
||||
dom.prop('value', obs),
|
||||
value ? [
|
||||
dom.autoDispose(value),
|
||||
dom.autoDispose(obs.addListener(v => value.set(v))),
|
||||
] : null,
|
||||
dom.prop('value', use => use(obs) ?? ''),
|
||||
(isValid ?
|
||||
(elem) => dom.autoDisposeElem(elem,
|
||||
subscribe(obs, (use) => isValid.set(elem.checkValidity()))) :
|
||||
null),
|
||||
options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null,
|
||||
onInput,
|
||||
options?.autoGrow ? [
|
||||
autoGrow(value!),
|
||||
dom.style('resize', 'none')
|
||||
] : null,
|
||||
dom.on('change', (e, elem) => setValue(elem)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,11 +49,13 @@ export type IconName = "ChartArea" |
|
||||
"Chat" |
|
||||
"Code" |
|
||||
"Collapse" |
|
||||
"Columns" |
|
||||
"Convert" |
|
||||
"Copy" |
|
||||
"CrossBig" |
|
||||
"CrossSmall" |
|
||||
"Database" |
|
||||
"Desktop" |
|
||||
"Dots" |
|
||||
"Download" |
|
||||
"DragDrop" |
|
||||
@@ -94,6 +96,7 @@ export type IconName = "ChartArea" |
|
||||
"Message" |
|
||||
"Minimize" |
|
||||
"Minus" |
|
||||
"Mobile" |
|
||||
"MobileChat" |
|
||||
"MobileChat2" |
|
||||
"NewNotification" |
|
||||
@@ -102,6 +105,7 @@ export type IconName = "ChartArea" |
|
||||
"Page" |
|
||||
"PanelLeft" |
|
||||
"PanelRight" |
|
||||
"Paragraph" |
|
||||
"Pencil" |
|
||||
"PinBig" |
|
||||
"PinSmall" |
|
||||
@@ -123,6 +127,8 @@ export type IconName = "ChartArea" |
|
||||
"Robot" |
|
||||
"Script" |
|
||||
"Search" |
|
||||
"Section" |
|
||||
"Separator" |
|
||||
"Settings" |
|
||||
"Share" |
|
||||
"Sort" |
|
||||
@@ -198,11 +204,13 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Chat",
|
||||
"Code",
|
||||
"Collapse",
|
||||
"Columns",
|
||||
"Convert",
|
||||
"Copy",
|
||||
"CrossBig",
|
||||
"CrossSmall",
|
||||
"Database",
|
||||
"Desktop",
|
||||
"Dots",
|
||||
"Download",
|
||||
"DragDrop",
|
||||
@@ -243,6 +251,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Message",
|
||||
"Minimize",
|
||||
"Minus",
|
||||
"Mobile",
|
||||
"MobileChat",
|
||||
"MobileChat2",
|
||||
"NewNotification",
|
||||
@@ -251,6 +260,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Page",
|
||||
"PanelLeft",
|
||||
"PanelRight",
|
||||
"Paragraph",
|
||||
"Pencil",
|
||||
"PinBig",
|
||||
"PinSmall",
|
||||
@@ -272,6 +282,8 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Robot",
|
||||
"Script",
|
||||
"Search",
|
||||
"Section",
|
||||
"Separator",
|
||||
"Settings",
|
||||
"Share",
|
||||
"Sort",
|
||||
|
||||
@@ -110,6 +110,7 @@ export const cssLabelText = styled('span', `
|
||||
font-weight: initial; /* negate bootstrap */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 16px;
|
||||
`);
|
||||
|
||||
type CheckboxArg = DomArg<HTMLInputElement>;
|
||||
|
||||
@@ -132,7 +132,7 @@ export function menuItemSubmenu(
|
||||
}
|
||||
|
||||
/**
|
||||
* Subheader as a menu item.
|
||||
* Header with a submenu (used in collapsed menus scenarios).
|
||||
*/
|
||||
export function menuSubHeaderMenu(
|
||||
submenu: weasel.MenuCreateFunc,
|
||||
@@ -557,7 +557,7 @@ export const menuItem = styled(weasel.menuItem, menuItemStyle);
|
||||
|
||||
export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle);
|
||||
|
||||
// when element name is, to long, it will be trimmed with ellipsis ("...")
|
||||
// when element name is too long, it will be trimmed with ellipsis ("...")
|
||||
export function menuItemTrimmed(
|
||||
action: (item: HTMLElement, ev: Event) => void, label: string, ...args: DomElementArg[]) {
|
||||
return menuItem(action, cssEllipsisLabel(label), ...args);
|
||||
@@ -584,7 +584,7 @@ export function menuItemCmd(
|
||||
typeof label === 'string'
|
||||
? dom('span', label, testId('cmd-name'))
|
||||
: dom('div', label(), testId('cmd-name')),
|
||||
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
|
||||
cmd.humanKeys?.length ? cssCmdKey(cmd.humanKeys[0]) : null,
|
||||
cssMenuItemCmd.cls(''), // overrides some menu item styles
|
||||
...args
|
||||
);
|
||||
@@ -826,3 +826,52 @@ const cssMenuSearchInput = styled('input', `
|
||||
color: ${theme.inputPlaceholderFg};
|
||||
}
|
||||
`);
|
||||
|
||||
type MenuDefinition = Array<MenuItem>;
|
||||
|
||||
|
||||
interface MenuItem {
|
||||
label?: string;
|
||||
header?: string;
|
||||
action?: string | (() => void);
|
||||
disabled?: boolean;
|
||||
icon?: IconName;
|
||||
shortcut?: string;
|
||||
submenu?: MenuDefinition;
|
||||
maxSubmenu?: number;
|
||||
type?: 'header' | 'separator' | 'item'; // default to item.
|
||||
}
|
||||
|
||||
export function buildMenu(definition: MenuDefinition, onclick?: (action: string) => any) {
|
||||
function *buildMenuItems(current: MenuDefinition): IterableIterator<Element> {
|
||||
for (const item of current) {
|
||||
const isHeader = item.type === 'header' || item.header;
|
||||
// If this is header with submenu.
|
||||
if (isHeader && item.submenu) {
|
||||
yield menuSubHeaderMenu(() => [...buildMenuItems(item.submenu!)], {}, item.header ?? item.label);
|
||||
continue;
|
||||
} else if (isHeader) {
|
||||
yield menuSubHeader(item.header ?? item.label);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a header, so it's an item or a separator.
|
||||
if (item.type === 'separator') {
|
||||
yield menuDivider();
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this is an item with submenu.
|
||||
if (item.submenu) {
|
||||
yield menuItemSubmenu(() => [...buildMenuItems(item.submenu!)], {}, item.label);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a submenu, so it's a regular item.
|
||||
const action = typeof item.action === 'function' ? item.action : () => onclick?.(item.action as string);
|
||||
yield menuItem(action, item.icon && menuIcon(item.icon), item.label, item.shortcut && cssCmdKey(item.shortcut));
|
||||
|
||||
}
|
||||
}
|
||||
return menu((ctl) => [...buildMenuItems(definition)], {});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {icon} from 'app/client/ui2018/icons';
|
||||
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
|
||||
import {choiceToken, DEFAULT_BACKGROUND_COLOR, DEFAULT_COLOR} from 'app/client/widgets/ChoiceToken';
|
||||
import {NTextBox} from 'app/client/widgets/NTextBox';
|
||||
import {Computed, dom, styled} from 'grainjs';
|
||||
import {WidgetType} from 'app/common/widgetTypes';
|
||||
import {Computed, dom, styled, UseCB} from 'grainjs';
|
||||
|
||||
export type IChoiceOptions = Style
|
||||
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
|
||||
@@ -84,8 +85,14 @@ export class ChoiceTextBox extends NTextBox {
|
||||
use => !use(disabled)
|
||||
&& (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions')))
|
||||
);
|
||||
|
||||
// If we are on forms, we don't want to show alignment options.
|
||||
const notForm = (use: UseCB) => {
|
||||
return use(use(this.field.viewSection).parentKey) !== WidgetType.Form;
|
||||
};
|
||||
|
||||
return [
|
||||
super.buildConfigDom(),
|
||||
dom.maybe(notForm, () => super.buildConfigDom()),
|
||||
cssLabel(t('CHOICES')),
|
||||
cssRow(
|
||||
dom.autoDispose(disabled),
|
||||
|
||||
@@ -35,6 +35,7 @@ import moment from 'moment';
|
||||
import maxSize from 'popper-max-size-modifier';
|
||||
import flatMap = require('lodash/flatMap');
|
||||
import {autoGrow} from 'app/client/ui/forms';
|
||||
import {autoFocus} from 'app/client/lib/domUtils';
|
||||
|
||||
const testId = makeTestId('test-discussion-');
|
||||
const t = makeT('DiscussionEditor');
|
||||
@@ -919,9 +920,6 @@ function bindProp(text: Observable<string>) {
|
||||
];
|
||||
}
|
||||
|
||||
function autoFocus() {
|
||||
return (el: HTMLElement) => void setTimeout(() => el.focus(), 10);
|
||||
}
|
||||
|
||||
function buildPopup(
|
||||
owner: Disposable,
|
||||
|
||||
@@ -22,7 +22,6 @@ import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
||||
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
|
||||
import { textButton } from 'app/client/ui2018/buttons';
|
||||
import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { theme } from 'app/client/ui2018/cssVars';
|
||||
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||
@@ -473,7 +472,7 @@ export class FieldBuilder extends Disposable {
|
||||
// the dom created by the widgetImpl to get out of sync.
|
||||
return dom('div',
|
||||
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
|
||||
dom('div', widget.buildConfigDom(), cssSeparator())
|
||||
dom('div', widget.buildConfigDom())
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -904,10 +903,6 @@ const cssTypeSelectMenu = styled('div', `
|
||||
max-height: 500px;
|
||||
`);
|
||||
|
||||
const cssSeparator = styled('div', `
|
||||
border-bottom: 1px solid ${theme.pagePanelsBorder};
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
// Simple helper that removes transparency from a HEX or rgba color.
|
||||
// User can set a transparent fill color using doc actions, but we don't want to show it well
|
||||
|
||||
@@ -9,8 +9,9 @@ import {icon} from 'app/client/ui2018/icons';
|
||||
import {IOptionFull, select} from 'app/client/ui2018/menus';
|
||||
import {NTextBox} from 'app/client/widgets/NTextBox';
|
||||
import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
|
||||
import {WidgetType} from 'app/common/widgetTypes';
|
||||
import {UIRowId} from 'app/plugin/GristAPI';
|
||||
import {Computed, dom, styled} from 'grainjs';
|
||||
import {Computed, dom, styled, UseCB} from 'grainjs';
|
||||
|
||||
|
||||
const t = makeT('Reference');
|
||||
@@ -48,10 +49,16 @@ export class Reference extends NTextBox {
|
||||
}
|
||||
|
||||
public buildConfigDom() {
|
||||
// If we are on forms, we don't want to show alignment options.
|
||||
const notForm = (use: UseCB) => {
|
||||
return use(use(this.field.viewSection).parentKey) !== WidgetType.Form;
|
||||
};
|
||||
return [
|
||||
this.buildTransformConfigDom(),
|
||||
cssLabel(t('CELL FORMAT')),
|
||||
super.buildConfigDom()
|
||||
dom.maybe(notForm, () => [
|
||||
cssLabel(t('CELL FORMAT')),
|
||||
super.buildConfigDom()
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
margin: -1px auto;
|
||||
width: 30px;
|
||||
height: 17px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.switch_slider {
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import {GristType} from 'app/plugin/GristData';
|
||||
import {CellValue, GristType} from 'app/plugin/GristData';
|
||||
import {MaybePromise} from 'app/plugin/gutil';
|
||||
import _ from 'lodash';
|
||||
import {marked} from 'marked';
|
||||
|
||||
/**
|
||||
* This file is a part of the Forms project. It contains a logic to render an HTML form from a JSON definition.
|
||||
* TODO: Client version has its own implementation, we should merge them but it is hard to tell currently
|
||||
* what are the similarities and differences as a Client code should also support browsing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* All allowed boxes.
|
||||
*/
|
||||
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field';
|
||||
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' |
|
||||
'Label';
|
||||
|
||||
/**
|
||||
* Number of fields to show in the form by default.
|
||||
*/
|
||||
export const INITIAL_FIELDS_COUNT = 9;
|
||||
|
||||
/**
|
||||
* Box model is a JSON that represents a form element. Every element can be converted to this element and every
|
||||
@@ -13,21 +27,37 @@ export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placehol
|
||||
export interface Box extends Record<string, any> {
|
||||
type: BoxType,
|
||||
children?: Array<Box>,
|
||||
|
||||
// Some properties used by some boxes (like form itself)
|
||||
submitText?: string,
|
||||
successURL?: string,
|
||||
successText?: string,
|
||||
anotherResponse?: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML.
|
||||
*/
|
||||
export interface RenderContext {
|
||||
root: Box;
|
||||
field(id: number): FieldModel;
|
||||
}
|
||||
|
||||
export interface FieldOptions {
|
||||
formRequired?: boolean;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
export interface FieldModel {
|
||||
/**
|
||||
* The question to ask. Fallbacks to column's label than column's id.
|
||||
*/
|
||||
question: string;
|
||||
description: string;
|
||||
colId: string;
|
||||
type: string;
|
||||
options: Record<string, any>;
|
||||
options: FieldOptions;
|
||||
values(): MaybePromise<[number, CellValue][]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,9 +66,7 @@ export interface FieldModel {
|
||||
*/
|
||||
export class RenderBox {
|
||||
public static new(box: Box, ctx: RenderContext): RenderBox {
|
||||
console.assert(box, `Box is not defined`);
|
||||
const ctr = elements[box.type];
|
||||
console.assert(ctr, `Box ${box.type} is not defined`);
|
||||
const ctr = elements[box.type] ?? Paragraph;
|
||||
return new ctr(box, ctx);
|
||||
}
|
||||
|
||||
@@ -46,48 +74,69 @@ export class RenderBox {
|
||||
|
||||
}
|
||||
|
||||
public toHTML(): string {
|
||||
return (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()).join('');
|
||||
public async toHTML(): Promise<string> {
|
||||
const proms = (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML());
|
||||
const parts = await Promise.all(proms);
|
||||
return parts.join('');
|
||||
}
|
||||
}
|
||||
|
||||
class Label extends RenderBox {
|
||||
public override async toHTML() {
|
||||
const text = this.box['text'];
|
||||
const cssClass = this.box['cssClass'] || '';
|
||||
return `
|
||||
<div class="grist-label ${cssClass}">${text || ''}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Paragraph extends RenderBox {
|
||||
public override toHTML(): string {
|
||||
public override async toHTML() {
|
||||
const text = this.box['text'] || '**Lorem** _ipsum_ dolor';
|
||||
const alignment = this.box['alignment'] || 'left';
|
||||
const html = marked(text);
|
||||
return `
|
||||
<div class="grist-paragraph">${html}</div>
|
||||
<div class="grist-paragraph grist-text-${alignment}">${html}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Section extends RenderBox {
|
||||
/** Nothing, default is enough */
|
||||
public override async toHTML() {
|
||||
return `
|
||||
<div class="grist-section">
|
||||
${await super.toHTML()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Columns extends RenderBox {
|
||||
public override toHTML(): string {
|
||||
const kids = this.box.children || [];
|
||||
public override async toHTML() {
|
||||
const size = this.box.children?.length || 1;
|
||||
const content = await super.toHTML();
|
||||
return `
|
||||
<div class="grist-columns" style='--grist-columns-count: ${kids.length}'>
|
||||
${kids.map((child) => child.toHTML()).join('\n')}
|
||||
<div class="grist-columns" style='--grist-columns-count: ${size}'>
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Submit extends RenderBox {
|
||||
public override toHTML() {
|
||||
public override async toHTML() {
|
||||
const text = _.escape(this.ctx.root['submitText'] || 'Submit');
|
||||
return `
|
||||
<div>
|
||||
<input type='submit' value='Submit' />
|
||||
<div class='grist-submit'>
|
||||
<input type='submit' value='${text}' />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Placeholder extends RenderBox {
|
||||
public override toHTML() {
|
||||
public override async toHTML() {
|
||||
return `
|
||||
<div>
|
||||
</div>
|
||||
@@ -105,93 +154,131 @@ class Layout extends RenderBox {
|
||||
*/
|
||||
class Field extends RenderBox {
|
||||
|
||||
public static render(field: FieldModel, context: RenderContext): string {
|
||||
public build(field: FieldModel, context: RenderContext) {
|
||||
const ctr = (questions as any)[field.type as any] as { new(): Question } || Text;
|
||||
return new ctr().toHTML(field, context);
|
||||
return new ctr();
|
||||
}
|
||||
|
||||
public toHTML(): string {
|
||||
public async toHTML() {
|
||||
const field = this.ctx.field(this.box['leaf']);
|
||||
if (!field) {
|
||||
return `<div class="grist-field">Field not found</div>`;
|
||||
}
|
||||
const label = field.question ? field.question : field.colId;
|
||||
const name = field.colId;
|
||||
let description = field.description || '';
|
||||
if (description) {
|
||||
description = `<div class='grist-field-description'>${description}</div>`;
|
||||
}
|
||||
const html = `<div class='grist-field-content'>${Field.render(field, this.ctx)}</div>`;
|
||||
const renderer = this.build(field, this.ctx);
|
||||
return `
|
||||
<div class="grist-field">
|
||||
<label for='${name}'>${label}</label>
|
||||
${html}
|
||||
${description}
|
||||
${await renderer.toHTML(field, this.ctx)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
interface Question {
|
||||
toHTML(field: FieldModel, context: RenderContext): string;
|
||||
toHTML(field: FieldModel, context: RenderContext): Promise<string>|string;
|
||||
}
|
||||
|
||||
|
||||
class Text implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
abstract class BaseQuestion implements Question {
|
||||
public async toHTML(field: FieldModel, context: RenderContext): Promise<string> {
|
||||
return `
|
||||
<input type='text' name='${field.colId}' />
|
||||
<div class='grist-question'>
|
||||
${this.label(field)}
|
||||
<div class='grist-field-content'>
|
||||
${await this.input(field, context)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public label(field: FieldModel): string {
|
||||
// This might be HTML.
|
||||
const label = field.question;
|
||||
const name = field.colId;
|
||||
return `
|
||||
<label class='grist-label' for='${name}'>${label}</label>
|
||||
`;
|
||||
}
|
||||
|
||||
public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>;
|
||||
}
|
||||
|
||||
class Text extends BaseQuestion {
|
||||
public input(field: FieldModel, context: RenderContext): string {
|
||||
const required = field.options.formRequired ? 'required' : '';
|
||||
return `
|
||||
<input type='text' name='${field.colId}' ${required}/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Date implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
class Date extends BaseQuestion {
|
||||
public input(field: FieldModel, context: RenderContext): string {
|
||||
const required = field.options.formRequired ? 'required' : '';
|
||||
return `
|
||||
<input type='date' name='${field.colId}' />
|
||||
<input type='date' name='${field.colId}' ${required}/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class DateTime implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
class DateTime extends BaseQuestion {
|
||||
public input(field: FieldModel, context: RenderContext): string {
|
||||
const required = field.options.formRequired ? 'required' : '';
|
||||
return `
|
||||
<input type='datetime-local' name='${field.colId}' />
|
||||
<input type='datetime-local' name='${field.colId}' ${required}/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Choice implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
class Choice extends BaseQuestion {
|
||||
public input(field: FieldModel, context: RenderContext): string {
|
||||
const required = field.options.formRequired ? 'required' : '';
|
||||
const choices: string[] = field.options.choices || [];
|
||||
return `
|
||||
<select name='${field.colId}'>
|
||||
<select name='${field.colId}' ${required} >
|
||||
${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Bool implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
class Bool extends BaseQuestion {
|
||||
public async toHTML(field: FieldModel, context: RenderContext) {
|
||||
return `
|
||||
<label>
|
||||
<input type='checkbox' name='${field.colId}' value="1" />
|
||||
Yes
|
||||
<div class='grist-question'>
|
||||
<div class='grist-field-content'>
|
||||
${this.input(field, context)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public input(field: FieldModel, context: RenderContext): string {
|
||||
const required = field.options.formRequired ? 'required' : '';
|
||||
const label = field.question ? field.question : field.colId;
|
||||
return `
|
||||
<label class='grist-switch'>
|
||||
<input type='checkbox' name='${field.colId}' value="1" ${required} />
|
||||
<div class="grist-widget_switch grist-switch_transition">
|
||||
<div class="grist-switch_slider"></div>
|
||||
<div class="grist-switch_circle"></div>
|
||||
</div>
|
||||
<span>${label}</span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceList implements Question {
|
||||
public toHTML(field: FieldModel, context: RenderContext): string {
|
||||
class ChoiceList extends BaseQuestion {
|
||||
public input(field: FieldModel, context: RenderContext): string {
|
||||
const required = field.options.formRequired ? 'required' : '';
|
||||
const choices: string[] = field.options.choices || [];
|
||||
return `
|
||||
<div name='${field.colId}' class='grist-choice-list'>
|
||||
<div name='${field.colId}' class='grist-choice-list ${required}'>
|
||||
${choices.map((choice) => `
|
||||
<label>
|
||||
<input type='checkbox' name='${field.colId}[]' value='${choice}' />
|
||||
${choice}
|
||||
<span>
|
||||
${choice}
|
||||
</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
@@ -199,6 +286,44 @@ class ChoiceList implements Question {
|
||||
}
|
||||
}
|
||||
|
||||
class RefList extends BaseQuestion {
|
||||
public async input(field: FieldModel, context: RenderContext) {
|
||||
const choices: [number, CellValue][] = (await field.values()) ?? [];
|
||||
// Sort by the second value, which is the display value.
|
||||
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
// Support for 20 choices, TODO: make it dynamic.
|
||||
choices.splice(20);
|
||||
return `
|
||||
<div name='${field.colId}' class='grist-ref-list'>
|
||||
${choices.map((choice) => `
|
||||
<label class='grist-checkbox'>
|
||||
<input type='checkbox' name='${field.colId}[]' value='${String(choice[0])}' />
|
||||
<span>
|
||||
${String(choice[1] ?? '')}
|
||||
</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class Ref extends BaseQuestion {
|
||||
public async input(field: FieldModel) {
|
||||
const choices: [number, CellValue][] = (await field.values()) ?? [];
|
||||
// Sort by the second value, which is the display value.
|
||||
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
|
||||
// Support for 1000 choices, TODO: make it dynamic.
|
||||
choices.splice(1000);
|
||||
// <option type='number' is not standard, we parse it ourselves.
|
||||
return `
|
||||
<select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}'>
|
||||
${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all available questions we will render of the form.
|
||||
* TODO: add other renderers.
|
||||
@@ -210,6 +335,8 @@ const questions: Partial<Record<GristType, new () => Question>> = {
|
||||
'ChoiceList': ChoiceList,
|
||||
'Date': Date,
|
||||
'DateTime': DateTime,
|
||||
'Ref': Ref,
|
||||
'RefList': RefList,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -223,4 +350,5 @@ const elements = {
|
||||
'Placeholder': Placeholder,
|
||||
'Layout': Layout,
|
||||
'Field': Field,
|
||||
'Label': Label,
|
||||
};
|
||||
|
||||
@@ -982,7 +982,7 @@ export function useBindable<T>(use: UseCBOwner, obs: BindableValue<T>): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* Use helper for simple boolean negation.
|
||||
* Useful helper for simple boolean negation.
|
||||
*/
|
||||
export const not = (obs: Observable<any>|IKnockoutReadObservable<any>) => (use: UseCBOwner) => !use(obs);
|
||||
|
||||
|
||||
@@ -9,3 +9,12 @@ export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;
|
||||
|
||||
// all widget types
|
||||
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | 'form' | IAttachedCustomWidget;
|
||||
export enum WidgetType {
|
||||
Table = 'record',
|
||||
Card = 'single',
|
||||
CardList = 'detail',
|
||||
Chart = 'chart',
|
||||
Custom = 'custom',
|
||||
Form = 'form',
|
||||
Calendar = 'custom.calendar',
|
||||
}
|
||||
|
||||
@@ -7,3 +7,5 @@ import times = require('lodash/times');
|
||||
export function arrayRepeat<T>(count: number, value: T): T[] {
|
||||
return times(count, constant(value));
|
||||
}
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
UserAction
|
||||
} from 'app/common/DocActions';
|
||||
import {DocData} from 'app/common/DocData';
|
||||
import {isRaisedException} from "app/common/gristTypes";
|
||||
import {Box, RenderBox, RenderContext} from "app/common/Forms";
|
||||
import {extractTypeFromColType, isRaisedException} from "app/common/gristTypes";
|
||||
import {Box, BoxType, FieldModel, INITIAL_FIELDS_COUNT, RenderBox, RenderContext} from "app/common/Forms";
|
||||
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
|
||||
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
|
||||
import {SchemaTypes} from "app/common/schema";
|
||||
@@ -86,11 +86,11 @@ import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload,
|
||||
import * as assert from 'assert';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import {Application, NextFunction, Request, RequestHandler, Response} from "express";
|
||||
import jsesc from 'jsesc';
|
||||
import * as fse from 'fs-extra';
|
||||
import * as handlebars from 'handlebars';
|
||||
import * as _ from "lodash";
|
||||
import LRUCache from 'lru-cache';
|
||||
import * as moment from 'moment';
|
||||
import * as fse from 'fs-extra';
|
||||
import fetch from 'node-fetch';
|
||||
import * as path from 'path';
|
||||
import * as t from "ts-interface-checker";
|
||||
@@ -159,6 +159,17 @@ function validateCore(checker: Checker, req: Request, body: any) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper used in forms rendering for purifying html.
|
||||
*/
|
||||
handlebars.registerHelper('dompurify', (html: string) => {
|
||||
return new handlebars.SafeString(`
|
||||
<script data-html="${handlebars.escapeExpression(html)}">
|
||||
document.write(DOMPurify.sanitize(document.currentScript.getAttribute('data-html')));
|
||||
</script>
|
||||
`);
|
||||
});
|
||||
|
||||
export class DocWorkerApi {
|
||||
// Map from docId to number of requests currently being handled for that doc
|
||||
private _currentUsage = new Map<string, number>();
|
||||
@@ -1398,90 +1409,129 @@ export class DocWorkerApi {
|
||||
sectionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the viewSection record for the specified id.
|
||||
const records = asRecords(await readTable(
|
||||
req, activeDoc, '_grist_Views_section', { id: [sectionId] }, {}
|
||||
));
|
||||
const section = records.find(r => r.id === sectionId);
|
||||
const Views_section = activeDoc.docData!.getMetaTable('_grist_Views_section');
|
||||
const section = Views_section.getRecord(sectionId);
|
||||
if (!section) {
|
||||
throw new ApiError('Form not found', 404);
|
||||
}
|
||||
|
||||
// Prepare the context that will be needed for rendering this form.
|
||||
const fields = asRecords(await readTable(
|
||||
req, activeDoc, '_grist_Views_section_field', { parentId: [sectionId] }, { }
|
||||
));
|
||||
const cols = asRecords(await readTable(
|
||||
req, activeDoc, '_grist_Tables_column', { parentId: [section.fields.tableRef] }, { }
|
||||
));
|
||||
const Tables = activeDoc.docData!.getMetaTable('_grist_Tables');
|
||||
const Views_section_field = activeDoc.docData!.getMetaTable('_grist_Views_section_field');
|
||||
const fields = Views_section_field.filterRecords({parentId: sectionId});
|
||||
const Tables_column = activeDoc.docData!.getMetaTable('_grist_Tables_column');
|
||||
|
||||
// Read the box specs
|
||||
const spec = section.fields.layoutSpec;
|
||||
const spec = section.layoutSpec;
|
||||
let box: Box = safeJsonParse(spec ? String(spec) : '', null);
|
||||
if (!box) {
|
||||
const editable = fields.filter(f => {
|
||||
const col = cols.find(c => c.id === f.fields.colRef);
|
||||
const col = Tables_column.getRecord(f.colRef);
|
||||
// Can't do attachments and formulas.
|
||||
return col && !(col.fields.isFormula && col.fields.formula) && col.fields.type !== 'Attachment';
|
||||
return col && !(col.isFormula && col.formula) && col.type !== 'Attachment';
|
||||
});
|
||||
box = {
|
||||
type: 'Layout',
|
||||
children: editable.map(f => ({
|
||||
type: 'Field',
|
||||
leaf: f.id
|
||||
}))
|
||||
children: [
|
||||
{type: 'Label'},
|
||||
{type: 'Label'},
|
||||
{
|
||||
type: 'Section',
|
||||
children: [
|
||||
{type: 'Label'},
|
||||
{type: 'Label'},
|
||||
...editable.slice(0, INITIAL_FIELDS_COUNT).map(f => ({
|
||||
type: 'Field' as BoxType,
|
||||
leaf: f.id
|
||||
}))
|
||||
]
|
||||
}
|
||||
],
|
||||
};
|
||||
box.children!.push({
|
||||
type: 'Submit'
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the table reads based on tableId. We are caching only the promise, not the result,
|
||||
const table = _.memoize(
|
||||
(tableId: string) => readTable(req, activeDoc, tableId, { }, { }).then(r => asRecords(r))
|
||||
);
|
||||
|
||||
const readValues = async (tId: string, colId: string) => {
|
||||
const records = await table(tId);
|
||||
return records.map(r => [r.id as number, r.fields[colId]]);
|
||||
};
|
||||
|
||||
const refValues = (col: MetaRowRecord<'_grist_Tables_column'>) => {
|
||||
return async () => {
|
||||
const refId = col.visibleCol;
|
||||
if (!refId) { return [] as any; }
|
||||
const refCol = Tables_column.getRecord(refId);
|
||||
if (!refCol) { return []; }
|
||||
const refTable = Tables.getRecord(refCol.parentId);
|
||||
if (!refTable) { return []; }
|
||||
const refTableId = refTable.tableId as string;
|
||||
const refColId = refCol.colId as string;
|
||||
if (!refTableId || !refColId) { return () => []; }
|
||||
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
|
||||
return await readValues(refTableId, refColId);
|
||||
};
|
||||
};
|
||||
|
||||
const context: RenderContext = {
|
||||
field(fieldRef: number) {
|
||||
const field = fields.find(f => f.id === fieldRef);
|
||||
field(fieldRef: number): FieldModel {
|
||||
const field = Views_section_field.getRecord(fieldRef);
|
||||
if (!field) { throw new Error(`Field ${fieldRef} not found`); }
|
||||
const col = cols.find(c => c.id === field.fields.colRef);
|
||||
if (!col) { throw new Error(`Column ${field.fields.colRef} not found`); }
|
||||
const fieldOptions = safeJsonParse(field.fields.widgetOptions as string, {});
|
||||
const colOptions = safeJsonParse(col.fields.widgetOptions as string, {});
|
||||
const col = Tables_column.getRecord(field.colRef);
|
||||
if (!col) { throw new Error(`Column ${field.colRef} not found`); }
|
||||
const fieldOptions = safeJsonParse(field.widgetOptions as string, {});
|
||||
const colOptions = safeJsonParse(col.widgetOptions as string, {});
|
||||
const options = {...colOptions, ...fieldOptions};
|
||||
const type = extractTypeFromColType(col.type as string);
|
||||
const colId = col.colId as string;
|
||||
|
||||
return {
|
||||
colId: col.fields.colId as string,
|
||||
description: options.description,
|
||||
question: options.question,
|
||||
type: (col.fields.type as string).split(':')[0],
|
||||
colId,
|
||||
description: fieldOptions.description || col.description,
|
||||
question: options.question || col.label || colId,
|
||||
options,
|
||||
type,
|
||||
// If this is reference field, we will need to fetch the referenced table.
|
||||
values: refValues(col)
|
||||
};
|
||||
}
|
||||
},
|
||||
root: box
|
||||
};
|
||||
|
||||
// Now render the box to HTML.
|
||||
const html = RenderBox.new(box, context).toHTML();
|
||||
|
||||
// The html will be inserted into a form as a replacement for:
|
||||
// document.write(sanitize(`<!-- INSERT CONTENT -->`))
|
||||
// We need to properly escape `
|
||||
const escaped = jsesc(html, {isScriptContext: true, quotes: 'backtick'});
|
||||
let redirectUrl = !box.successURL ? '' : box.successURL;
|
||||
// Make sure it is a valid URL.
|
||||
try {
|
||||
new URL(redirectUrl);
|
||||
} catch (e) {
|
||||
redirectUrl = '';
|
||||
}
|
||||
|
||||
const html = await RenderBox.new(box, context).toHTML();
|
||||
// And wrap it with the form template.
|
||||
const form = await fse.readFile(path.join(getAppPathTo(this._staticPath, 'static'),
|
||||
'forms/form.html'), 'utf8');
|
||||
// TODO: externalize css. Currently the redirect mechanism depends on the relative base URL, so
|
||||
// we can't change it at this moment. But once custom success page will be implemented this should
|
||||
// be possible.
|
||||
|
||||
const staticOrigin = process.env.APP_STATIC_URL || "";
|
||||
const staticBaseUrl = `${staticOrigin}/v/${this._grist.getTag()}/`;
|
||||
// Fill out the blanks and send the result.
|
||||
const doc = await this._dbManager.getDoc(req);
|
||||
const docUrl = await this._grist.getResourceUrl(doc, 'html');
|
||||
const tableId = await getRealTableId(String(section.fields.tableRef), {activeDoc, req});
|
||||
res.status(200).send(form
|
||||
.replace('<!-- INSERT CONTENT -->', escaped || '')
|
||||
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">`)
|
||||
.replace('<!-- INSERT DOC URL -->', docUrl)
|
||||
.replace('<!-- INSERT TABLE ID -->', tableId)
|
||||
);
|
||||
const tableId = await getRealTableId(String(section.tableRef), {activeDoc, req});
|
||||
|
||||
const template = handlebars.compile(form);
|
||||
const renderedHtml = template({
|
||||
// Trusted content generated by us.
|
||||
BASE: staticBaseUrl,
|
||||
DOC_URL: await this._grist.getResourceUrl(doc, 'html'),
|
||||
TABLE_ID: tableId,
|
||||
ANOTHER_RESPONSE: Boolean(box.anotherResponse),
|
||||
// Not trusted content entered by user.
|
||||
CONTENT: html,
|
||||
SUCCESS_TEXT: box.successText || `Thank you! Your response has been recorded.`,
|
||||
SUCCESS_URL: redirectUrl,
|
||||
});
|
||||
res.status(200).send(renderedHtml);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1501,7 +1551,7 @@ export class DocWorkerApi {
|
||||
|
||||
// Check that the request is for a valid section in the document.
|
||||
const sections = docData.getMetaTable('_grist_Views_section');
|
||||
const section = sections.getRecords().find(s => s.id === sectionId);
|
||||
const section = sections.getRecord(sectionId);
|
||||
if (!section) {
|
||||
throw new ApiError('Form not found', 404);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user