diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts index b5379ff8..5e125428 100644 --- a/app/client/components/Forms/Columns.ts +++ b/app/client/components/Forms/Columns.ts @@ -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 { // 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 { + 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) { diff --git a/app/client/components/Forms/Editor.ts b/app/client/components/Forms/Editor.ts new file mode 100644 index 00000000..174966c0 --- /dev/null +++ b/app/client/components/Forms/Editor.ts @@ -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, + /** 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, +} + +export function buildEditor(props: Props, ...args: IDomArgs) { + 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, + ); +} diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts index 1c325c72..8cf05477 100644 --- a/app/client/components/Forms/Field.ts +++ b/app/client/components/Forms/Field.ts @@ -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; } + /** + * 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 { + // 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, + overlay: Observable, + onSave: (value: string) => void, + }, ...args: IDomArgs) { + 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, + 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 = Computed.create(this, use => { - return use(use(field.origCol).widgetOptionsJson.prop('choices')) || []; + return use(use(use(field).origCol).widgetOptionsJson.prop('choices')) || []; + }); + const typedChoices = Computed.create(this, use => { + const value = use(choices); + // Make sure it is array of strings. + if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { + return []; + } + return value; }); - return style.cssSelect( + return css.cssSelect( {tabIndex: "-1"}, ignoreClick, - dom.prop('name', this.field.colId), - dom.forEach(choices, (choice) => dom('option', choice, {value: choice})), + dom.prop('name', use => use(use(field).colId)), + dom.forEach(typedChoices, (choice) => dom('option', choice, {value: choice})), ); } } class ChoiceListModel extends Question { - public buildDom() { - const field = this.field; + public renderInput() { + const field = this.model.field; const choices: Computed = 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, + overlay: Observable, + question: Observable, + 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 { function testType(value: BindableValue) { 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}; +`); diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index 79d14e56..aff01889 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -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; public selectedBox: Observable; + public selectedColumns: ko.Computed|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) => { + 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 { - 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 - } - , - ...args: IDomArgs - ) { - const {box, overlay} = options; - const myOwner = new MultiHolder(); - if (owner) { - owner.autoDispose(myOwner); - } - - let element: HTMLElement; - const dragHover = Observable.create(myOwner, false); - - myOwner.autoDispose(this.selectedBox.addListener(v => { - if (v !== box) { return; } - if (!element) { return; } - element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'}); - })); - - const isSelected = Computed.create(myOwner, use => { - if (!this.viewSection || this.viewSection.isDisposed()) { return false; } - if (use(this.selectedBox) === box) { - // We are only selected when the section is also selected. - return use(this.viewSection.hasFocus); - } - return false; - }); - - return style.cssFieldEditor( - testId('editor'), - style.cssDrag(), - - dom.maybe(overlay, () => this.buildOverlay(myOwner, box)), - - owner ? null : dom.autoDispose(myOwner), - (el) => { element = el; }, - // Control panel - style.cssControls( - style.cssControlsLabel(dom.text(box.type)), ), - - // Turn on active like state when we clicked here. - style.cssFieldEditor.cls('-selected', isSelected), - style.cssFieldEditor.cls('-cut', use => use(box.cut)), - testId('field-editor-selected', isSelected), - - // Select on click. - (el) => { - dom.onElem(el, 'click', (ev) => { - // Only if the click was in this element. - const target = ev.target as HTMLElement; - if (!target.closest) { return; } - // Make sure that the closest editor is this one. - const closest = target.closest(`.${style.cssFieldEditor.className}`); - if (closest !== el) { return; } - - // It looks like we clicked somewhere in this editor, and not inside any other inside. - this.selectedBox.set(box); - ev.stopPropagation(); - ev.preventDefault(); - ev.stopImmediatePropagation(); - }); - }, - - // Attach menu - menus.menu((ctl) => { - this.menuHolder.autoDispose(ctl); - this.selectedBox.set(box); - const field = (type: string) => ({field: type}); - const struct = (structure: string) => ({structure}); - const above = (el: {field: string} | {structure: string}) => () => allCommands.insertFieldBefore.run(el); - const below: typeof above = (el) => () => allCommands.insertFieldAfter.run(el); - const quick = ['Text', 'Numeric', 'Choice', 'Date']; - const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId()); - const isQuick = ({colType}: {colType: string}) => quick.includes(colType); - const notQuick = ({colType}: {colType: string}) => !quick.includes(colType); - const insertMenu = (where: typeof above) => () => { - return [ - menus.menuSubHeader('New question'), - ...commonTypes() - .filter(isQuick) - .map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)) - , - menus.menuItemSubmenu( - () => commonTypes() - .filter(notQuick) - .map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)), - {}, - menus.menuIcon('Dots'), - dom('span', "More", dom.style('margin-right', '8px')) - ), - menus.menuDivider(), - menus.menuSubHeader('Static element'), - menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",), - menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"), - menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",), - // menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ), - ]; - }; - - - return [ - menus.menuItemSubmenu(insertMenu(above), {action: above(field('Text'))}, "Insert question above"), - menus.menuItemSubmenu(insertMenu(below), {action: below(field('Text'))}, "Insert question below"), - menus.menuDivider(), - menus.menuItemCmd(allCommands.contextMenuCopy, "Copy"), - menus.menuItemCmd(allCommands.contextMenuCut, "Cut"), - menus.menuItemCmd(allCommands.contextMenuPaste, "Paste"), - menus.menuDivider(), - menus.menuItemCmd(allCommands.deleteFields, "Hide"), - ]; - }, {trigger: ['contextmenu']}), - - dom.on('contextmenu', (ev) => { - ev.stopPropagation(); - ev.preventDefault(); - }), - - // And now drag and drop support. - {draggable: "true"}, - - // When started, we just put the box into the dataTransfer as a plain text. - // TODO: this might be very sofisticated in the future. - dom.on('dragstart', (ev) => { - // Prevent propagation, as we might be in a nested editor. - ev.stopPropagation(); - ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON())); - ev.dataTransfer!.dropEffect = "move"; - }), - - dom.on('dragover', (ev) => { - // As usual, prevent propagation. - ev.stopPropagation(); - ev.preventDefault(); - // Here we just change the style of the element. - ev.dataTransfer!.dropEffect = "move"; - dragHover.set(true); - }), - - dom.on('dragleave', (ev) => { - ev.stopPropagation(); - ev.preventDefault(); - // Just remove the style and stop propagation. - dragHover.set(false); - }), - - dom.on('drop', async (ev) => { - ev.stopPropagation(); - ev.preventDefault(); - dragHover.set(false); - const dropped = parseBox(ev.dataTransfer!.getData('text/plain')); - // We need to remove it from the parent, so find it first. - const droppedId = dropped.id; - if (droppedId === box.id) { return; } - const droppedRef = this._root.find(droppedId); - await this.bundle(async () => { - await this._root.save(); - droppedRef?.removeSelf(); - await box.drop(dropped)?.onDrop(); - await this._saveNow(); - }); - }), - - style.cssFieldEditor.cls('-drag-hover', dragHover), - - ...args, + dom.on('click', () => this.selectedBox.set(null)) ); } - public buildOverlay(owner: IDisposableOwner, box: BoxModel) { + public buildOverlay(...args: IDomArgs) { return style.cssSelectedOverlay( + ...args, ); } - public async addNewQuestion(insert: Place, type: string) { + public async addNewQuestion(insert: Place, action: {add: string}|{show: string}) { await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => { - // First save the layout, so that - await this._saveNow(); - // Now that the layout is saved, we won't be bottered with autogenerated layout, + // First save the layout, so that we don't have autogenerated layout. + await this.save(); + // Now that the layout is saved, we won't be bothered with autogenerated layout, // and we can safely insert to column. - const {fieldRef} = await this.insertColumn(null, { - colInfo: { - type, - } - }); - + let fieldRef = 0; + if ('show' in action) { + fieldRef = await this.showColumn(action.show); + } else { + const result = await this.insertColumn(null, { + colInfo: { + type: action.add, + } + }); + fieldRef = result.fieldRef; + } // And add it into the layout. this.selectedBox.set(insert({ leaf: fieldRef, type: 'Field' })); - await this._root.save(); }, {nestInActiveBundle: true}); } - public buildAddMenu(insert: Place, { - onClose: onClose = () => {}, - onOpen: onOpen = () => {}, - customItems = [] as Element[], - } = {}) { - return menus.menu( - (ctl) => { - onOpen(); - ctl.onDispose(onClose); - - const field = (colType: BoxType) => ({field: colType}); - const struct = (structure: BoxType) => ({structure}); - const where = (el: {field: string} | {structure: BoxType}) => () => { - if ('field' in el) { - return this.addNewQuestion(insert, el.field); - } else { - insert(components.defaultElement(el.structure)); - return this._root.save(); - } - }; - const quick = ['Text', 'Numeric', 'Choice', 'Date']; - const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId()); - const isQuick = ({colType}: {colType: string}) => quick.includes(colType); - const notQuick = ({colType}: {colType: string}) => !quick.includes(colType); - return [ - menus.menuSubHeader('New question'), - ...commonTypes() - .filter(isQuick) - .map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)) - , - menus.menuItemSubmenu( - () => commonTypes() - .filter(notQuick) - .map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)), - {}, - menus.menuIcon('Dots'), - dom('span', "More", dom.style('margin-right', '8px')) - ), - menus.menuDivider(), - menus.menuSubHeader('Static element'), - menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",), - menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"), - menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",), - // menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ), - elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}), - customItems.length ? menus.menuDivider(dom.style('min-width', '200px')) : null, - ...customItems, - ]; - }, - { - selectOnOpen: true, - trigger: [ - 'click', - ], - } - ); - } - - private async _saveNow() { + public async save() { try { this._saving = true; const newVersion = {...this._root.toJSON()}; @@ -690,13 +427,27 @@ export class FormView extends Disposable { confirmModal(t('Publish your form?'), t('Publish'), async () => { - await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { - const page = this.viewSection.view().page(); - if (!page) { - throw new Error('Unable to publish form: undefined page'); + const page = this.viewSection.view().page(); + if (!page) { + throw new Error('Unable to publish form: undefined page'); + } + let validShare = page.shareRef() !== 0; + // If page is shared, make sure home server is aware of it. + if (validShare) { + try { + const pageShare = page.share(); + const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId()); + validShare = !!serverShare; + } catch(ex) { + // TODO: for now ignore the error, but the UI should be updated to not show editor + if (ex.code === 'AUTH_NO_OWNER') { + return; + } + throw ex; } - - if (page.shareRef() === 0) { + } + await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { + if (!validShare) { const shareRef = await this.gristDoc.docModel.docData.sendAction([ 'AddRecord', '_grist_Shares', @@ -715,6 +466,7 @@ export class FormView extends Disposable { await share.optionsObj.save(); } + await this.save(); this.viewSection.shareOptionsObj.update({ form: true, publish: true, @@ -780,31 +532,34 @@ export class FormView extends Disposable { }, ); } - private _buildSwitcher() { - - const toggle = (val: boolean) => () => { - this.isEdit.set(val); - this._saveNow().catch(reportError); - }; - + private _buildPublisher() { return style.cssSwitcher( this._buildSwitcherMessage(), style.cssButtonGroup( style.cssIconButton( - icon('Pencil'), - testId('edit'), - dom('div', 'Editor'), - cssButton.cls('-primary', this.isEdit), - style.cssIconButton.cls('-standard', not(this.isEdit)), - dom.on('click', toggle(true)) + style.cssIconButton.cls('-frameless'), + icon('Revert'), + testId('reset'), + dom('div', 'Reset form'), + dom.style('margin-right', 'auto'), // move it to the left + dom.on('click', () => { + this._resetForm().catch(reportError); + }) ), - style.cssIconButton( - icon('EyeShow'), - dom('div', 'Preview'), + style.cssIconLink( testId('preview'), - cssButton.cls('-primary', not(this.isEdit)), - style.cssIconButton.cls('-standard', (this.isEdit)), - dom.on('click', toggle(false)) + icon('EyeShow'), + dom.text('Preview'), + dom.prop('href', this._url), + dom.prop('target', '_blank'), + dom.on('click', async (ev) => { + // If this form is not yet saved, we will save it first. + if (!this._savedLayout) { + stopEvent(ev); + await this.save(); + window.open(this._url.get()); + } + }) ), style.cssIconButton( icon('FieldAttachment'), @@ -831,6 +586,10 @@ export class FormView extends Disposable { }); await copyToClipboard(url); showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'}); + } catch(ex) { + if (ex.code === 'AUTH_NO_OWNER') { + throw new Error('Publishing form is only available to owners'); + } } finally { this._copyingLink.set(false); } @@ -876,8 +635,81 @@ export class FormView extends Disposable { ); }); } + + /** + * Generates a form template based on the fields in the view section. + */ + private _formTemplate(fields: ViewFieldRec[]) { + const boxes: Box[] = fields.map(f => { + return { + type: 'Field', + leaf: f.id() + } as Box; + }); + const section = { + type: 'Section', + children: [ + {type: 'Paragraph', text: SECTION_TITLE}, + {type: 'Paragraph', text: SECTION_DESC}, + ...boxes, + ], + }; + return { + type: 'Layout', + children: [ + {type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, + {type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, + section, + {type: 'Submit'} + ] + }; + } + + private async _resetForm() { + this.selectedBox.set(null); + await this.gristDoc.docData.bundleActions('Reset form', async () => { + // First we will remove all fields from this section, and add top 9 back. + const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId()); + + const toAdd = this.viewSection.table().columns().peek().filter(c => { + // If hidden than no. + if (c.isHiddenCol()) { return false; } + + // If formula column, no. + if (c.isFormula() && c.formula()) { return false; } + + return true; + }); + toAdd.sort((a, b) => a.parentPos() - b.parentPos()); + + const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id()); + const parentId = colRef.map(() => this.viewSection.id()); + const parentPos = colRef.map((_, i) => i + 1); + const ids = colRef.map(() => null); + + await this.gristDoc.docData.sendActions([ + ['BulkRemoveRecord', '_grist_Views_section_field', toDelete], + ['BulkAddRecord', '_grist_Views_section_field', ids, { + colRef, + parentId, + parentPos, + }], + ]); + + const fields = this.viewSection.viewFields().all().slice(0, 9); + await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields)); + }); + } } // Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts defaults(FormView.prototype, BaseView.prototype); Object.assign(FormView.prototype, BackboneEvents); + +// Default values when form is reset. +const FORM_TITLE = "## **My Super Form**"; +const FORM_DESC = "This is the UI design work in progress on Grist Forms. We are working hard to " + + "give you the best possible experience with this feature"; + +const SECTION_TITLE = '### **Header**'; +const SECTION_DESC = 'Description'; diff --git a/app/client/components/Forms/HiddenQuestionConfig.ts b/app/client/components/Forms/HiddenQuestionConfig.ts deleted file mode 100644 index 24a4aec3..00000000 --- a/app/client/components/Forms/HiddenQuestionConfig.ts +++ /dev/null @@ -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; -`); diff --git a/app/client/components/Forms/Label.ts b/app/client/components/Forms/Label.ts new file mode 100644 index 00000000..3233346c --- /dev/null +++ b/app/client/components/Forms/Label.ts @@ -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; + const cssClass = this.prop('cssClass', '') as Observable; + 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; + } + }}) + ); + } +} diff --git a/app/client/components/Forms/Menu.ts b/app/client/components/Forms/Menu.ts new file mode 100644 index 00000000..0d7a2cb8 --- /dev/null +++ b/app/client/components/Forms/Menu.ts @@ -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): IDomArgs { + 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, + ]; +} diff --git a/app/client/components/Forms/Model.ts b/app/client/components/Forms/Model.ts index 884f97f8..2a6d013d 100644 --- a/app/client/components/Forms/Model.ts +++ b/app/client/components/Forms/Model.ts @@ -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; +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; + 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; /** * 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; 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 { + public hasProp(name: string) { + return this.props.hasOwnProperty(name); + } + + public async save(before?: () => Promise): Promise { 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 { + 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 { + public * iterate(): IterableIterator { 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, public view: FormView) { + constructor( + box: Box, + public parent: BoxModel | null, + public _save: (clb?: Callback) => Promise, + 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, -} - -export type Builder = (owner: MultiHolder, options: { - box: BoxModel, - view: FormView, - overlay: Observable, -}) => DomContents; - export const ignoreClick = dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); diff --git a/app/client/components/Forms/Paragraph.ts b/app/client/components/Forms/Paragraph.ts index 868146ab..650b97ac 100644 --- a/app/client/components/Forms/Paragraph.ts +++ b/app/client/components/Forms/Paragraph.ts @@ -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; - 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; - 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}; } diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts index d96c5665..4a5a12c7 100644 --- a/app/client/components/Forms/Section.ts +++ b/app/client/components/Forms/Section.ts @@ -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', ` +`); diff --git a/app/client/components/Forms/Submit.ts b/app/client/components/Forms/Submit.ts index c2c8115e..9989d8c4 100644 --- a/app/client/components/Forms/Submit.ts +++ b/app/client/components/Forms/Submit.ts @@ -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")) + ); } } diff --git a/app/client/components/Forms/Text.ts b/app/client/components/Forms/Text.ts deleted file mode 100644 index 6bb92543..00000000 --- a/app/client/components/Forms/Text.ts +++ /dev/null @@ -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')), - ]), - ); -}; diff --git a/app/client/components/Forms/UnmappedFieldsConfig.ts b/app/client/components/Forms/UnmappedFieldsConfig.ts new file mode 100644 index 00000000..b022eb95 --- /dev/null +++ b/app/client/components/Forms/UnmappedFieldsConfig.ts @@ -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}) { + 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}) { + 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; + } +`); diff --git a/app/client/components/Forms/elements.ts b/app/client/components/Forms/elements.ts index 16539e64..6e68def6 100644 --- a/app/client/components/Forms/elements.ts +++ b/app/client/components/Forms/elements.ts @@ -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}; } } diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index 9274d2dc..4068fda9 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -1,72 +1,177 @@ +import {textarea} from 'app/client/ui/inputs'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; -import {basicButton} from 'app/client/ui2018/buttons'; +import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; import {colors, theme, vars} from 'app/client/ui2018/cssVars'; -import {BindableValue, dom, IDomArgs, styled, subscribeBindable} from 'grainjs'; +import {icon} from 'app/client/ui2018/icons'; +import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs'; import {marked} from 'marked'; -export { - cssLabel, - cssDesc, - cssInput, - cssFieldEditor, - cssSelectedOverlay, - cssControls, - cssControlsLabel, - cssAddElement, - cssAddText, - cssFormContainer, - cssFormEdit, - cssFormEditBody, - cssSection, - cssStaticText, -}; - -const cssFormEditBody = styled('div', ` - width: 100%; - overflow: auto; - padding-top: 52px; -`); - -const cssFormEdit = styled('div', ` +export const cssFormView = styled('div.flexauto.flexvbox', ` color: ${theme.text}; - background-color: ${theme.leftPanelBg}; display: flex; flex-direction: column; flex-basis: 0px; align-items: center; justify-content: space-between; position: relative; + background-color: ${theme.leftPanelBg}; + overflow: auto; + min-height: 100%; + width: 100%; +`); - --section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */ - &, &-preview { - background-color: ${theme.leftPanelBg}; - overflow: auto; - min-height: 100%; - width: 100%; - position: relative; - flex-basis: 0px; +export const cssFormContainer = styled('div', ` + background-color: ${theme.mainPanelBg}; + border: 1px solid ${theme.modalBorderDark}; + color: ${theme.text}; + width: 600px; + align-self: center; + margin: 0px auto; + border-radius: 3px; + display: flex; + flex-direction: column; + max-width: calc(100% - 32px); + padding-top: 20px; + padding-left: 48px; + padding-right: 48px; + gap: 8px; +`); + + + +export const cssFieldEditor = styled('div.hover_border.field_editor', ` + position: relative; + cursor: pointer; + user-select: none; + outline: none; + padding: 8px; + border-radius: 3px; + margin-bottom: 4px; + --hover-visible: hidden; + transition: transform 0.2s ease-in-out; + &:hover:not(:has(.hover_border:hover),&-cut) { + --hover-visible: visible; + outline: 1px solid ${colors.lightGreen}; + } + &-selected:not(&-cut) { + background: #F7F7F7; + outline: 1px solid ${colors.lightGreen}; + --selected-block: block; + } + &:active:not(:has(&:active)) { + outline: 1px solid ${colors.darkGreen}; + } + &-drag-hover { + outline: 2px dashed ${colors.lightGreen}; + outline-offset: 2px; + } + &-cut { + outline: 2px dashed ${colors.orange}; + outline-offset: 2px; + } + &-FormDescription { + margin-bottom: 10px; + } + &-drag-above { + transform: translateY(2px); } + &-drag-below { + transform: translateY(-2px); + } +`); + +export const cssSectionEditor = styled('div', ` + border-radius: 3px; + padding: 16px; + border: 1px solid ${theme.modalBorderDark}; `); -const cssLabel = styled('label', ` + +export const cssSection = styled('div', ` + position: relative; + color: ${theme.text}; + margin: 0px auto; + min-height: 50px; + .${cssFormView.className}-preview & { + background: transparent; + border-radius: unset; + padding: 0px; + min-height: auto; + } +`); + +export const cssLabel = styled('label', ` font-size: 15px; font-weight: normal; - margin-bottom: 8px; user-select: none; display: block; + margin: 0px; +`); + +export const cssCheckboxLabel = styled('label', ` + font-size: 15px; + font-weight: normal; + user-select: none; + display: flex; + align-items: center; + gap: 8px; + margin: 0px; + margin-bottom: 8px; `); -const cssDesc = styled('div', ` - font-size: 10px; +export function textbox(obs: Observable, ...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, ...args: IDomArgs) { - 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, 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); + } + }), + ]; +} diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index eab011ca..af9f6b7d 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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, columnId: Observable) { + 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; } diff --git a/app/client/components/LayoutTray.ts b/app/client/components/LayoutTray.ts index 8b0bcdae..d0657395 100644 --- a/app/client/components/LayoutTray.ts +++ b/app/client/components/LayoutTray.ts @@ -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; } diff --git a/app/client/components/ViewConfigTab.js b/app/client/components/ViewConfigTab.js index 2aac898a..90036d3a 100644 --- a/app/client/components/ViewConfigTab.js +++ b/app/client/components/ViewConfigTab.js @@ -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'), + ) + ); }); }; diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index c8e54b37..40d93ac5 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -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); diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index e3fabd3e..bf32a778 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -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' }, { diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 1d227569..7bee81cc 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -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; + widgetType: modelUtil.KoSaveableObservable; + // Save all filters of fields/columns in the section. saveFilters(): Promise; @@ -276,9 +279,19 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO insertColumn(colId?: string|null, options?: InsertColOptions): Promise; + /** + * 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 - removeField(colRef: number): Promise; + /** + * Removes one or multiple fields. + * @param colRef + */ + removeField(colRef: number|Array): Promise; } 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); + } }; } diff --git a/app/client/ui/DescriptionConfig.ts b/app/client/ui/DescriptionConfig.ts index fdae4195..23dab8a3 100644 --- a/app/client/ui/DescriptionConfig.ts +++ b/app/client/ui/DescriptionConfig.ts @@ -52,18 +52,20 @@ export function buildDescriptionConfig( export function buildTextInput( owner: MultiHolder, options: { - value: KoSaveableObservable, - cursor: ko.Computed, label: string, + value: KoSaveableObservable, + cursor?: ko.Computed, placeholder?: ko.Computed, }, ...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; diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index f8e6d0cd..16c53174 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -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({ diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index b832a6ff..b00090a1 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -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, 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}; diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts index a42f1c13..e7714f3f 100644 --- a/app/client/ui/ViewLayoutMenu.ts +++ b/app/client/ui/ViewLayoutMenu.ts @@ -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"), diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index 6329eb87..e378bedb 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -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'}), ), ) diff --git a/app/client/ui/forms.ts b/app/client/ui/forms.ts index 00f06a16..ac82133f 100644 --- a/app/client/ui/forms.ts +++ b/app/client/ui/forms.ts @@ -84,9 +84,20 @@ function resize(el: HTMLTextAreaElement) { } export function autoGrow(text: Observable) { + // 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) diff --git a/app/client/ui/inputs.ts b/app/client/ui/inputs.ts index 1ac18863..2f4b71e1 100644 --- a/app/client/ui/inputs.ts +++ b/app/client/ui/inputs.ts @@ -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, ...args: DomElement ); } +export interface ITextAreaOptions extends IInputOptions { + autoGrow?: boolean; + save?: (value: string) => void; +} + export function textarea( - obs: Observable, options: IInputOptions, ...args: IDomArgs + obs: Observable, options?: ITextAreaOptions|null, ...args: IDomArgs ): 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)), ); } diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index cd9735a1..a20720ad 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -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", diff --git a/app/client/ui2018/checkbox.ts b/app/client/ui2018/checkbox.ts index 77122084..35f09c77 100644 --- a/app/client/ui2018/checkbox.ts +++ b/app/client/ui2018/checkbox.ts @@ -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; diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index 61f5839d..c27834e8 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -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; + + +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 { + 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)], {}); +} diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index c4a87ef3..75327d02 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -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; @@ -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), diff --git a/app/client/widgets/DiscussionEditor.ts b/app/client/widgets/DiscussionEditor.ts index 491ea8bb..1a88e23f 100644 --- a/app/client/widgets/DiscussionEditor.ts +++ b/app/client/widgets/DiscussionEditor.ts @@ -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) { ]; } -function autoFocus() { - return (el: HTMLElement) => void setTimeout(() => el.focus(), 10); -} function buildPopup( owner: Disposable, diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index f2343fdd..ed6165ea 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -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 diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts index f1d94446..35572103 100644 --- a/app/client/widgets/Reference.ts +++ b/app/client/widgets/Reference.ts @@ -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() + ]) ]; } diff --git a/app/client/widgets/Switch.css b/app/client/widgets/Switch.css index 5bc086d4..d9cb7ea0 100644 --- a/app/client/widgets/Switch.css +++ b/app/client/widgets/Switch.css @@ -3,6 +3,7 @@ margin: -1px auto; width: 30px; height: 17px; + flex: none; } .switch_slider { diff --git a/app/common/Forms.ts b/app/common/Forms.ts index 0ca0e596..1df20895 100644 --- a/app/common/Forms.ts +++ b/app/common/Forms.ts @@ -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 { type: BoxType, children?: Array, + + // 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; + 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 { + 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 ` +
${text || ''}
+ `; } } 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 ` -
${html}
+
${html}
`; } } class Section extends RenderBox { - /** Nothing, default is enough */ + public override async toHTML() { + return ` +
+ ${await super.toHTML()} +
+ `; + } } 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 ` -
- ${kids.map((child) => child.toHTML()).join('\n')} +
+ ${content}
`; } } class Submit extends RenderBox { - public override toHTML() { + public override async toHTML() { + const text = _.escape(this.ctx.root['submitText'] || 'Submit'); return ` -
- +
+
`; } } class Placeholder extends RenderBox { - public override toHTML() { + public override async toHTML() { return `
@@ -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 `
Field not found
`; } - const label = field.question ? field.question : field.colId; - const name = field.colId; - let description = field.description || ''; - if (description) { - description = `
${description}
`; - } - const html = `
${Field.render(field, this.ctx)}
`; + const renderer = this.build(field, this.ctx); return `
- - ${html} - ${description} + ${await renderer.toHTML(field, this.ctx)}
`; } } interface Question { - toHTML(field: FieldModel, context: RenderContext): string; + toHTML(field: FieldModel, context: RenderContext): Promise|string; } +abstract class BaseQuestion implements Question { + public async toHTML(field: FieldModel, context: RenderContext): Promise { + return ` +
+ ${this.label(field)} +
+ ${await this.input(field, context)} +
+
+ `; + } -class Text implements Question { - public toHTML(field: FieldModel, context: RenderContext): string { + public label(field: FieldModel): string { + // This might be HTML. + const label = field.question; + const name = field.colId; return ` - + `; } + + public abstract input(field: FieldModel, context: RenderContext): string|Promise; } -class Date implements Question { - public toHTML(field: FieldModel, context: RenderContext): string { +class Text extends BaseQuestion { + public input(field: FieldModel, context: RenderContext): string { + const required = field.options.formRequired ? 'required' : ''; return ` - + `; } } -class DateTime implements Question { - public toHTML(field: FieldModel, context: RenderContext): string { +class Date extends BaseQuestion { + public input(field: FieldModel, context: RenderContext): string { + const required = field.options.formRequired ? 'required' : ''; return ` - + `; } } -class Choice implements Question { - public toHTML(field: FieldModel, context: RenderContext): string { +class DateTime extends BaseQuestion { + public input(field: FieldModel, context: RenderContext): string { + const required = field.options.formRequired ? 'required' : ''; + return ` + + `; + } +} + +class Choice extends BaseQuestion { + public input(field: FieldModel, context: RenderContext): string { + const required = field.options.formRequired ? 'required' : ''; const choices: string[] = field.options.choices || []; return ` - ${choices.map((choice) => ``).join('')} `; } } -class Bool implements Question { - public toHTML(field: FieldModel, context: RenderContext): string { +class Bool extends BaseQuestion { + public async toHTML(field: FieldModel, context: RenderContext) { + return ` +
+
+ ${this.input(field, context)} +
+
+ `; + } + + public input(field: FieldModel, context: RenderContext): string { + const required = field.options.formRequired ? 'required' : ''; + const label = field.question ? field.question : field.colId; return ` -