(core) Forms Improvements

Summary:
 - Forms now have a reset button.
 - Choice and Reference fields in forms now have an improved select menu.
 - Formula and attachments column types are no longer mappable or visible in forms.
 - Fields in a form widget are now removed if their column is deleted.
 - The preview button in a published form widget has been replaced with a view button. It now opens the published form in a new tab.
 - A new share menu for published form widgets, with options to copy a link or embed code.
 - Forms can now have multiple sections.
 - Form widgets now indicate when publishing is unavailable (e.g. in forks or unsaved documents).
 - General improvements to form styling.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4203
This commit is contained in:
George Gevoian
2024-03-20 10:51:59 -04:00
parent aff9c7075c
commit 418681915e
40 changed files with 1643 additions and 617 deletions

View File

@@ -9,6 +9,7 @@ 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, IDomArgs, MultiHolder, Observable, styled} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
const testId = makeTestId('test-forms-');
@@ -93,17 +94,23 @@ export class ColumnsModel extends BoxModel {
const fieldsToRemove = (Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[]);
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
// Remove each child of this column from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this column from the layout.
this.removeSelf();
// Finally, remove the fields and save the changes to the layout.
await this.parent?.save(async () => {
// FormView is particularly sensitive to the order that view fields and
// the form layout are modified. Specifically, if the layout is
// modified before view fields are removed, deleting a column with
// mapped fields inside seems to break. The same issue affects sections
// containing mapped fields. Reversing the order causes no such issues.
//
// TODO: narrow down why this happens and see if it's worth fixing.
if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove);
}
// Remove each child of this column from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this column from the layout.
this.removeSelf();
});
}
}
@@ -218,16 +225,12 @@ export class PlaceholderModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {type: 'Paragraph', text, alignment};
}
export function Placeholder(): FormLayoutNode {
return {type: 'Placeholder'};
return {id: uuidv4(), type: 'Placeholder'};
}
export function Columns(): FormLayoutNode {
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
return {id: uuidv4(), type: 'Columns', children: [Placeholder(), Placeholder()]};
}
const cssPlaceholder = styled('div', `

View File

@@ -7,7 +7,7 @@ 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';
import {BindableValue, dom, DomContents, IDomArgs, MultiHolder, Observable} from 'grainjs';
const testId = makeTestId('test-forms-');
const t = makeT('FormView.Editor');
@@ -27,9 +27,13 @@ interface Props {
*/
click?: (ev: MouseEvent, box: BoxModel) => void,
/**
* Custom remove icon. If null, then no drop icon is shown.
* Whether to show the remove button. Defaults to true.
*/
removeIcon?: IconName|null,
showRemoveButton?: BindableValue<boolean>,
/**
* Custom remove icon.
*/
removeIcon?: IconName,
/**
* Custom remove button rendered atop overlay.
*/
@@ -212,7 +216,12 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
}
await box.save(async () => {
await box.accept(dropped, wasBelow ? 'below' : 'above')?.afterDrop();
// When a field is dragged from the creator panel, it has a colId instead of a fieldRef (as there is no
// field yet). In this case, we need to create a field first.
if (dropped.type === 'Field' && typeof dropped.leaf === 'string') {
dropped.leaf = await view.showColumn(dropped.leaf);
}
box.accept(dropped, wasBelow ? 'below' : 'above');
});
}),
@@ -225,10 +234,9 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
testId('element'),
dom.attr('data-box-model', String(box.type)),
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,
dom.maybe(props.showRemoveButton ?? true, () => [
props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),
]),
...args,
);
}

View File

@@ -1,4 +1,4 @@
import {CHOOSE_TEXT, FormLayoutNode} from 'app/client/components/FormRenderer';
import {FormLayoutNode, SELECT_PLACEHOLDER} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
@@ -8,6 +8,7 @@ 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 {isBlankValue} from 'app/common/gristTypes';
import {Constructor, not} from 'app/common/gutil';
import {
BindableValue,
@@ -102,18 +103,6 @@ export class FieldModel extends BoxModel {
);
}
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 override render(...args: IDomArgs<HTMLElement>): HTMLElement {
// Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
const save = (value: string) => {
@@ -287,20 +276,14 @@ class TextModel extends Question {
class ChoiceModel extends Question {
protected choices: Computed<string[]> = Computed.create(this, use => {
// Read choices from field.
const list = use(use(use(this.model.field).origCol).widgetOptionsJson.prop('choices')) || [];
const choices = use(use(this.model.field).widgetOptionsJson.prop('choices'));
// Make sure it is array of strings.
if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) {
// Make sure it is an array of strings.
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
return [];
} else {
return choices;
}
return list;
});
protected choicesWithEmpty = Computed.create(this, use => {
const list: Array<string|null> = Array.from(use(this.choices));
// Add empty choice if not present.
list.unshift(null);
return list;
});
public renderInput(): HTMLElement {
@@ -309,21 +292,27 @@ class ChoiceModel extends Question {
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', use => use(use(field).colId)),
dom.forEach(this.choicesWithEmpty, (choice) => dom('option', choice ?? CHOOSE_TEXT, {value: choice ?? ''})),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})),
);
}
}
class ChoiceListModel extends ChoiceModel {
private _choices = Computed.create(this, use => {
// Support for 30 choices. TODO: make limit dynamic.
return use(this.choices).slice(0, 30);
});
public renderInput() {
const field = this.model.field;
return dom('div',
dom.prop('name', use => use(use(field).colId)),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
choice
)),
dom.maybe(use => use(this.choices).length === 0, () => [
dom.maybe(use => use(this._choices).length === 0, () => [
dom('div', 'No choices defined'),
]),
);
@@ -382,22 +371,22 @@ class DateTimeModel extends Question {
}
class RefListModel extends Question {
protected choices = this._subscribeForChoices();
protected options = this._getOptions();
public renderInput() {
return dom('div',
dom.prop('name', this.model.colId),
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
dom.forEach(this.options, (option) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
String(choice[1] ?? '')
option.label,
)),
dom.maybe(use => use(this.choices).length === 0, () => [
dom('div', 'No choices defined'),
dom.maybe(use => use(this.options).length === 0, () => [
dom('div', 'No values in show column of referenced table'),
]),
) as HTMLElement;
}
private _subscribeForChoices() {
private _getOptions() {
const tableId = Computed.create(this, use => {
const refTable = use(use(this.model.column).refTable);
return refTable ? use(refTable.tableId) : '';
@@ -411,27 +400,23 @@ class RefListModel extends Question {
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
return use(observer)
.filter(([_id, value]) => !isBlankValue(value))
.map(([id, value]) => ({label: String(value), value: String(id)}))
.sort((a, b) => a.label.localeCompare(b.label))
.slice(0, 30); // TODO: make limit dynamic.
});
}
}
class RefModel extends RefListModel {
protected withEmpty = Computed.create(this, use => {
const list = Array.from(use(this.choices));
// Add empty choice if not present.
list.unshift(['', CHOOSE_TEXT]);
return list;
});
public renderInput() {
return css.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.model.colId),
dom.forEach(this.withEmpty, (choice) => dom('option', String(choice[1] ?? ''), {value: String(choice[0])})),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.options, ({label, value}) => dom('option', label, {value})),
);
}
}

View File

@@ -1,7 +1,7 @@
import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {FormLayoutNode, FormLayoutNodeType, patchLayoutSpec} from 'app/client/components/FormRenderer';
import * as components from 'app/client/components/Forms/elements';
import {NewBox} from 'app/client/components/Forms/Menu';
import {BoxModel, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
@@ -15,13 +15,16 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {ShareRec} from 'app/client/models/entities/ShareRec';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {SortedRowSet} from 'app/client/models/rowset';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {menuCssClass} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
import {isOwner} from 'app/common/roles';
@@ -31,6 +34,7 @@ import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid';
import * as ko from 'knockout';
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
const t = makeT('FormView');
@@ -42,6 +46,7 @@ export class FormView extends Disposable {
public viewSection: ViewSectionRec;
public selectedBox: Computed<BoxModel | null>;
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
public disableDeleteSection: Computed<boolean>;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
@@ -49,17 +54,20 @@ export class FormView extends Disposable {
protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _formFields: Computed<ViewFieldRec[]>;
private _autoLayout: Computed<FormLayoutNode>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
private _url: Computed<string>;
private _copyingLink: Observable<boolean>;
private _previewUrl: Computed<string>;
private _pageShare: Computed<ShareRec | null>;
private _remoteShare: AsyncComputed<{key: string}|null>;
private _isFork: Computed<boolean>;
private _published: Computed<boolean>;
private _showPublishedMessage: Observable<boolean>;
private _isOwner: boolean;
private _openingForm: Observable<boolean>;
private _formElement: HTMLElement;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
@@ -124,15 +132,22 @@ export class FormView extends Disposable {
}));
this.viewSection.selectedFields(this.selectedColumns.peek());
this._formFields = Computed.create(this, use => {
const fields = use(use(this.viewSection.viewFields).getObservable());
return fields.filter(f => use(use(f.column).isFormCol));
});
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) {
const fields = use(use(this.viewSection.viewFields).getObservable());
const fields = use(this._formFields);
const layout = use(this.viewSection.layoutSpecObj);
if (!layout || !layout.id) {
return this._formTemplate(fields);
} else {
const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id())));
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
return patchedLayout;
}
return existing;
});
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
@@ -166,12 +181,7 @@ export class FormView extends Disposable {
copy: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
// Add this box as a json to clipboard.
const json = selected.toJSON();
navigator.clipboard.writeText(JSON.stringify({
...json,
id: uuidv4(),
})).catch(reportError);
selected.copySelf().catch(reportError);
},
cut: () => {
const selected = this.selectedBox.get();
@@ -179,7 +189,7 @@ export class FormView extends Disposable {
selected.cutSelf().catch(reportError);
},
paste: () => {
const doPast = async () => {
const doPaste = async () => {
const boxInClipboard = parseBox(await navigator.clipboard.readText());
if (!boxInClipboard) { return; }
if (!this.selectedBox.get()) {
@@ -187,13 +197,14 @@ export class FormView extends Disposable {
} else {
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
}
// Remove the original box from the clipboard.
const cut = this._root.find(boxInClipboard.id);
cut?.removeSelf();
const maybeCutBox = this._root.find(boxInClipboard.id);
if (maybeCutBox?.cut.get()) {
maybeCutBox.removeSelf();
}
await this._root.save();
await navigator.clipboard.writeText('');
};
doPast().catch(reportError);
doPaste().catch(reportError);
},
nextField: () => {
const current = this.selectedBox.get();
@@ -242,7 +253,7 @@ export class FormView extends Disposable {
},
clearValues: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if (!selected || selected.canRemove?.() === false) { return; }
keyboardActions.nextField();
this.bundle(async () => {
await selected.deleteSelf();
@@ -267,6 +278,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);
} else {
selected.insertBefore(components.defaultElement(what.structure));
this.save().catch(reportError);
}
},
insertField: (what: NewBox) => {
@@ -287,6 +299,7 @@ export class FormView extends Disposable {
this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);
} else {
selected.insertAfter(components.defaultElement(what.structure));
this.save().catch(reportError);
}
},
showColumns: (colIds: string[]) => {
@@ -299,6 +312,7 @@ export class FormView extends Disposable {
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; }
const box = {
id: uuidv4(),
leaf: fieldRef,
type: 'Field' as FormLayoutNodeType,
};
@@ -332,7 +346,7 @@ export class FormView extends Disposable {
hideFields: keyboardActions.hideFields,
}, this, this.viewSection.hasFocus));
this._url = Computed.create(this, use => {
this._previewUrl = Computed.create(this, use => {
const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; }
const url = urlState().makeUrl({
@@ -344,8 +358,6 @@ export class FormView extends Disposable {
return url;
});
this._copyingLink = Observable.create(this, false);
this._pageShare = Computed.create(this, use => {
const page = use(use(this.viewSection.view).page);
if (!page) { return null; }
@@ -366,7 +378,15 @@ export class FormView extends Disposable {
}
});
this._isFork = Computed.create(this, use => {
const {docPageModel} = this.gristDoc;
return use(docPageModel.isFork) || use(docPageModel.isPrefork);
});
this._published = Computed.create(this, use => {
const isFork = use(this._isFork);
if (isFork) { return false; }
const pageShare = use(this._pageShare);
const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty);
const validShare = pageShare && remoteShare;
@@ -384,6 +404,8 @@ export class FormView extends Disposable {
this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());
this._openingForm = Observable.create(this, false);
// Last line, build the dom.
this.viewPane = this.autoDispose(this.buildDom());
}
@@ -401,7 +423,7 @@ export class FormView extends Disposable {
testId('editor'),
style.cssFormEditBody(
style.cssFormContainer(
dom.forEach(this._root.children, (child) => {
this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
if (!child) {
return dom('div', 'Empty node');
}
@@ -410,11 +432,12 @@ export class FormView extends Disposable {
throw new Error('Element is not an HTMLElement');
}
return element;
}),
})),
this._buildPublisher(),
),
),
dom.on('click', () => this.selectedBox.set(null))
dom.on('click', () => this.selectedBox.set(null)),
dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
);
}
@@ -443,6 +466,7 @@ export class FormView extends Disposable {
}
// And add it into the layout.
this.selectedBox.set(insert({
id: uuidv4(),
leaf: fieldRef,
type: 'Field'
}));
@@ -612,67 +636,90 @@ export class FormView extends Disposable {
private _buildPublisher() {
return style.cssSwitcher(
this._buildSwitcherMessage(),
this._buildNotifications(),
style.cssButtonGroup(
style.cssSmallIconButton(
style.cssIconButton.cls('-frameless'),
style.cssSmallButton(
style.cssSmallButton.cls('-frameless'),
icon('Revert'),
testId('reset'),
dom('div', 'Reset form'),
dom('div', t('Reset form')),
dom.style('visibility', use => use(this._published) ? 'hidden' : 'visible'),
dom.style('margin-right', 'auto'), // move it to the left
dom.on('click', () => {
this._resetForm().catch(reportError);
return confirmModal(t('Are you sure you want to reset your form?'),
t('Reset'),
() => this._resetForm(),
);
})
),
style.cssIconLink(
testId('preview'),
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'),
testId('link'),
dom('div', 'Copy Link'),
dom.prop('disabled', this._copyingLink),
dom.show(use => this._isOwner && use(this._published)),
dom.on('click', async (_event, element) => {
try {
this._copyingLink.set(true);
const data = typeof ClipboardItem !== 'function' ? await this._getFormLink() : new ClipboardItem({
"text/plain": this._getFormLink().then(text => new Blob([text], {type: 'text/plain'})),
});
await copyToClipboard(data);
showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'});
} catch (ex) {
if (ex.code === 'AUTH_NO_OWNER') {
throw new Error('Sharing a form is only available to owners');
}
} finally {
this._copyingLink.set(false);
}
}),
),
dom.domComputed(this._published, published => {
if (published) {
return style.cssSmallButton(
testId('view'),
icon('EyeShow'),
t('View'),
dom.boolAttr('disabled', this._openingForm),
dom.on('click', async (ev) => {
// If this form is not yet saved, we will save it first.
if (!this._savedLayout) {
await this.save();
}
try {
this._openingForm.set(true);
window.open(await this._getFormUrl());
} finally {
this._openingForm.set(false);
}
})
);
} else {
return style.cssSmallLinkButton(
testId('preview'),
icon('EyeShow'),
t('Preview'),
dom.attr('href', this._previewUrl),
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._previewUrl.get());
}
})
);
}
}),
style.cssSmallButton(
icon('Share'),
testId('share'),
dom('div', t('Share')),
dom.show(use => this._isOwner && use(this._published)),
elem => {
setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), {
...defaultMenuOptions,
placement: 'top-end',
});
},
),
dom.domComputed(use => {
const isFork = use(this._isFork);
const published = use(this._published);
return published
? style.cssIconButton(
dom('div', 'Unpublish'),
? style.cssSmallButton(
dom('div', t('Unpublish')),
dom.show(this._isOwner),
style.cssIconButton.cls('-warning'),
style.cssSmallButton.cls('-warning'),
dom.on('click', () => this._handleClickUnpublish()),
testId('unpublish'),
)
: style.cssIconButton(
dom('div', 'Publish'),
: style.cssSmallButton(
dom('div', t('Publish')),
dom.boolAttr('disabled', isFork),
!isFork ? null : hoverTooltip(t('Save your document to publish this form.'), {
placement: 'top',
}),
dom.show(this._isOwner),
cssButton.cls('-primary'),
dom.on('click', () => this._handleClickPublish()),
@@ -683,7 +730,7 @@ export class FormView extends Disposable {
);
}
private async _getFormLink() {
private async _getFormUrl() {
const share = this._pageShare.get();
if (!share) {
throw new Error('Unable to get form link: form is not published');
@@ -703,7 +750,139 @@ export class FormView extends Disposable {
});
}
private _buildSwitcherMessage() {
private _buildShareMenu(ctl: IOpenController) {
const formUrl = Observable.create<string | null>(ctl, null);
const showEmbedCode = Observable.create(this, false);
const embedCode = Computed.create(ctl, formUrl, (_use, url) => {
if (!url) { return null; }
return '<iframe style="border: none; width: 640px; ' +
`height: ${this._getEstimatedFormHeightPx()}px" src="${url}"></iframe>`;
});
// Reposition the popup when its height changes.
ctl.autoDispose(formUrl.addListener(() => ctl.update()));
ctl.autoDispose(showEmbedCode.addListener(() => ctl.update()));
this._getFormUrl()
.then((url) => {
if (ctl.isDisposed()) { return; }
formUrl.set(url);
})
.catch((e) => {
ctl.close();
reportError(e);
});
return style.cssShareMenu(
dom.cls(menuCssClass),
style.cssShareMenuHeader(
style.cssShareMenuCloseButton(
icon('CrossBig'),
dom.on('click', () => ctl.close()),
),
),
style.cssShareMenuBody(
dom.domComputed(use => {
const url = use(formUrl);
const code = use(embedCode);
if (!url || !code) {
return style.cssShareMenuSpinner(loadingSpinner());
}
return [
dom('div',
style.cssShareMenuSectionHeading(
t('Share this form'),
),
dom('div',
style.cssShareMenuHintText(
t('Anyone with the link below can see the empty form and submit a response.'),
),
style.cssShareMenuUrlBlock(
style.cssShareMenuUrl(
{readonly: true, value: url},
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
),
style.cssShareMenuCopyButton(
testId('link'),
t('Copy link'),
dom.on('click', async (_ev, el) => {
await copyToClipboard(url);
showTransientTooltip(
el,
t('Link copied to clipboard'),
{key: 'share-form-menu'}
);
})
),
),
),
),
dom.domComputed(showEmbedCode, (showCode) => {
if (!showCode) {
return dom('div',
style.cssShareMenuEmbedFormButton(
t('Embed this form'),
dom.on('click', () => showEmbedCode.set(true)),
)
);
} else {
return dom('div',
style.cssShareMenuSectionHeading(t('Embed this form')),
dom.maybe(showEmbedCode, () => style.cssShareMenuCodeBlock(
style.cssShareMenuCode(
code,
{readonly: true, rows: '3'},
dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }),
),
style.cssShareMenuCodeBlockButtons(
style.cssShareMenuCopyButton(
testId('code'),
t('Copy code'),
dom.on('click', async (_ev, el) => {
await copyToClipboard(code);
showTransientTooltip(
el,
t('Code copied to clipboard'),
{key: 'share-form-menu'}
);
}),
),
),
)),
);
}
}),
];
}),
),
);
}
private _getEstimatedFormHeightPx() {
return (
// Form content height.
this._formElement.scrollHeight +
// Plus top/bottom page padding.
(2 * 52) +
// Plus top/bottom form padding.
(2 * 20) +
// Plus minimum form error height.
38 +
// Plus form footer height.
64
);
}
private _buildNotifications() {
return [
this._buildFormPublishedNotification(),
];
}
private _buildFormPublishedNotification() {
return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {
return style.cssSwitcherMessage(
style.cssSwitcherMessageBody(
@@ -726,29 +905,24 @@ export class FormView extends Disposable {
/**
* Generates a form template based on the fields in the view section.
*/
private _formTemplate(fields: ViewFieldRec[]) {
private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode {
const boxes: FormLayoutNode[] = fields.map(f => {
return {
id: uuidv4(),
type: 'Field',
leaf: f.id()
} as FormLayoutNode;
leaf: f.id(),
};
});
const section = {
type: 'Section',
children: [
{type: 'Paragraph', text: SECTION_TITLE},
{type: 'Paragraph', text: SECTION_DESC},
...boxes,
],
};
const section = components.Section(...boxes);
return {
id: uuidv4(),
type: 'Layout',
children: [
{type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
section,
{type: 'Submit'}
]
{id: uuidv4(), type: 'Submit'},
],
};
}
@@ -758,19 +932,9 @@ export class FormView extends Disposable {
// 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; }
// Attachments are currently unsupported in forms.
if (c.pureType() === 'Attachments') { return false; }
return true;
});
toAdd.sort((a, b) => a.parentPos() - b.parentPos());
const toAdd = this.viewSection.table().columns().peek()
.filter(c => c.isFormCol())
.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());
@@ -799,6 +963,3 @@ Object.assign(FormView.prototype, BackboneEvents);
// Default values when form is reset.
const FORM_TITLE = "## **Form Title**";
const FORM_DESC = "Your form description goes here.";
const SECTION_TITLE = '### **Header**';
const SECTION_DESC = 'Description';

View File

@@ -16,7 +16,7 @@ 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 {
export class MappedFieldsConfig extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
@@ -28,7 +28,8 @@ export class UnmappedFieldsConfig extends Disposable {
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()));
const cols = this._section.table().visibleColumns()
.filter(c => c.isFormCol() && !fields.has(c.colId()));
return cols.map(col => ({
col,
selected: Observable.create(null, false),
@@ -38,11 +39,12 @@ export class UnmappedFieldsConfig extends Disposable {
if (this._section.isDisposed()) {
return [];
}
const cols = this._section.viewFields().map(f => f.column());
const cols = this._section.viewFields().map(f => f.column()).all()
.filter(c => c.isFormCol());
return cols.map(col => ({
col,
selected: Observable.create(null, false),
})).all();
}));
})));
const anyUnmappedSelected = Computed.create(this, use => {
@@ -64,36 +66,6 @@ export class UnmappedFieldsConfig extends Disposable {
};
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(
@@ -124,6 +96,36 @@ export class UnmappedFieldsConfig extends Disposable {
testId('visible-batch-buttons')
),
),
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')
),
),
];
}

View File

@@ -49,12 +49,7 @@ export function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArg
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 normalCols = use(viewSection.hiddenColumns).filter(col => use(col.isFormCol));
const list = normalCols.map(col => {
return {
label: use(col.label),

View File

@@ -2,7 +2,6 @@ import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRend
import * as elements from 'app/client/components/Forms/elements';
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';
type Callback = () => Promise<void>;
@@ -33,9 +32,7 @@ export abstract class BoxModel extends Disposable {
}
/**
* The id of the created box. The value here is not important. It is only used as a plain old pointer to this
* element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need
* it as box are serialized to JSON and put into clipboard, and we need to be able to find them back.
* The unique id of the box.
*/
public id: string;
/**
@@ -77,8 +74,7 @@ export abstract class BoxModel extends Disposable {
parent.children.autoDispose(this);
}
// Store "pointer" to this element.
this.id = uuidv4();
this.id = box.id;
// Create observables for all properties.
this.type = box.type;
@@ -93,15 +89,6 @@ export abstract class BoxModel extends Disposable {
this.onCreate();
}
/**
* Public method that should be called when this box is dropped somewhere. In derived classes
* 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 afterDrop() {
}
/**
* The only method that derived classes need to implement. It should return a DOM element that
* represents this box.
@@ -134,12 +121,19 @@ export abstract class BoxModel extends Disposable {
}
/**
* Cuts self and puts it into clipboard.
* Copies self and puts it into clipboard.
*/
public async cutSelf() {
public async copySelf() {
[...this.root().traverse()].forEach(box => box?.cut.set(false));
// Add this box as a json to clipboard.
await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
}
/**
* Cuts self and puts it into clipboard.
*/
public async cutSelf() {
await this.copySelf();
this.cut.set(true);
}
@@ -339,27 +333,28 @@ export abstract class BoxModel extends Disposable {
this.prop(key).set(boxDef[key]);
}
// Add or delete any children that were removed or added.
const myLength = this.children.get().length;
const newLength = boxDef.children ? boxDef.children.length : 0;
if (myLength > newLength) {
this.children.splice(newLength, myLength - newLength);
} else if (myLength < newLength) {
for (let i = myLength; i < newLength; i++) {
const toPush = boxDef.children![i];
this.children.push(toPush && BoxModel.new(toPush, this));
// First remove any children from the model that aren't in `boxDef`.
const boxDefChildren = boxDef.children ?? [];
const boxDefChildrenIds = new Set(boxDefChildren.map(c => c.id));
for (const child of this.children.get()) {
if (!boxDefChildrenIds.has(child.id)) {
child.removeSelf();
}
}
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];
atIndex.update(atIndexDef);
// Then add or update the children from `boxDef` to the model.
const newChildren: BoxModel[] = [];
const modelChildrenById = new Map(this.children.get().map(c => [c.id, c]));
for (const boxDefChild of boxDefChildren) {
if (!boxDefChild.id || !modelChildrenById.has(boxDefChild.id)) {
newChildren.push(BoxModel.new(boxDefChild, this));
} else {
const existingChild = modelChildrenById.get(boxDefChild.id)!;
existingChild.update(boxDefChild);
newChildren.push(existingChild);
}
}
this.children.set(newChildren);
}
/**
@@ -381,12 +376,18 @@ export abstract class BoxModel extends Disposable {
}
}
public canRemove() {
return true;
}
protected onCreate() {
}
}
export class LayoutModel extends BoxModel {
public disableDeleteSection: Computed<boolean>;
constructor(
box: FormLayoutNode,
public parent: BoxModel | null,
@@ -394,6 +395,9 @@ export class LayoutModel extends BoxModel {
public view: FormView
) {
super(box, parent, view);
this.disableDeleteSection = Computed.create(this, use => {
return use(this.children).filter(c => c.type === 'Section').length === 1;
});
}
public async save(clb?: Callback) {

View File

@@ -1,10 +1,12 @@
import * as css from './styles';
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {BoxModel} from 'app/client/components/Forms/Model';
import * as css from 'app/client/components/Forms/styles';
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';
import {v4 as uuidv4} from 'uuid';
export class ParagraphModel extends BoxModel {
public edit = Observable.create(this, false);
@@ -60,6 +62,10 @@ export class ParagraphModel extends BoxModel {
}
}
export function Paragraph(text: string, alignment?: 'left'|'right'|'center'): FormLayoutNode {
return {id: uuidv4(), type: 'Paragraph', text, alignment};
}
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};

View File

@@ -1,11 +1,19 @@
import {allCommands} from 'app/client/components/commands';
import {FormLayoutNode} from 'app/client/components/FormRenderer';
import {buildEditor} from 'app/client/components/Forms/Editor';
import {FieldModel} from 'app/client/components/Forms/Field';
import {FormView} from 'app/client/components/Forms/FormView';
import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model';
import {BoxModel, LayoutModel} from 'app/client/components/Forms/Model';
import {Paragraph} from 'app/client/components/Forms/Paragraph';
import * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization';
import * as menus from 'app/client/ui2018/menus';
import {dom, styled} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
const t = makeT('FormView');
const testId = makeTestId('test-forms-');
@@ -13,14 +21,17 @@ const testId = makeTestId('test-forms-');
* Component that renders a section of the form.
*/
export class SectionModel extends BoxModel {
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
}
public override render(): HTMLElement {
const children = this.children;
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,
showRemoveButton: use => !use((this.root() as LayoutModel).disableDeleteSection),
// Content is just a list of children.
content: style.cssSection(
// Wrap them in a div that mutes hover events.
@@ -35,6 +46,18 @@ export class SectionModel extends BoxModel {
style.cssPlusIcon('Plus'),
buildMenu({
box: this,
customItems: [
menus.menuItem(
() => allCommands.insertFieldBefore.run({structure: 'Section'}),
menus.menuIcon('Section'),
t('Insert section above'),
),
menus.menuItem(
() => allCommands.insertFieldAfter.run({structure: 'Section'}),
menus.menuIcon('Section'),
t('Insert section below'),
),
],
})
),
)
@@ -79,19 +102,35 @@ export class SectionModel extends BoxModel {
const fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[];
const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());
// Remove each child of this section from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this section from the layout.
this.removeSelf();
// Finally, remove the fields and save the changes to the layout.
await this.parent?.save(async () => {
// Remove the fields.
if (fieldIdsToRemove.length > 0) {
await this.view.viewSection.removeField(fieldIdsToRemove);
}
// Remove each child of this section from the layout.
this.children.get().forEach(child => { child.removeSelf(); });
// Remove this section from the layout.
this.removeSelf();
});
}
public canRemove() {
return !((this.parent as LayoutModel).disableDeleteSection.get());
}
}
export function Section(...children: FormLayoutNode[]): FormLayoutNode {
return {
id: uuidv4(),
type: 'Section',
children: [
Paragraph('### **Header**'),
Paragraph('Description'),
...children,
],
};
}
const cssSectionItems = styled('div.hover_border', `

View File

@@ -1,5 +1,8 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
import {Columns, Paragraph, Placeholder} from 'app/client/components/Forms/Columns';
import {Columns, Placeholder} from 'app/client/components/Forms/Columns';
import {Paragraph} from 'app/client/components/Forms/Paragraph';
import {Section} from 'app/client/components/Forms/Section';
import {v4 as uuidv4} from 'uuid';
/**
* Add any other element you whish to use in the form here.
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
@@ -18,6 +21,7 @@ export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
case 'Placeholder': return Placeholder();
case 'Separator': return Paragraph('---');
case 'Header': return Paragraph('## **Header**', 'center');
default: return {type};
case 'Section': return Section();
default: return {id: uuidv4(), type};
}
}

View File

@@ -1,6 +1,6 @@
import {textarea} from 'app/client/ui/inputs';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton, bigBasicButton, bigBasicButtonLink} from 'app/client/ui2018/buttons';
import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons';
import {colors, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
@@ -239,14 +239,6 @@ export const cssSelect = styled('select', `
border-radius: 3px;
outline: none;
pointer-events: none;
&-invalid {
color: ${theme.inputInvalid};
}
&:has(option[value='']:checked) {
font-style: italic;
color: ${colors.slate};
}
`);
export const cssFieldEditorContent = styled('div', `
@@ -373,13 +365,22 @@ export const cssButtonGroup = styled('div', `
`);
export const cssIconLink = styled(bigBasicButtonLink, `
export const cssSmallLinkButton = styled(basicButtonLink, `
display: flex;
align-items: center;
gap: 4px;
min-height: 26px;
`);
&-standard {
background-color: ${theme.leftPanelBg};
export const cssSmallButton = styled(basicButton, `
display: flex;
align-items: center;
gap: 4px;
min-height: 26px;
&-frameless {
background-color: transparent;
border: none;
}
&-warning {
color: ${theme.controlPrimaryFg};
@@ -391,45 +392,6 @@ export const cssIconLink = styled(bigBasicButtonLink, `
background-color: #B8791B;
border: none;
}
&-frameless {
background-color: transparent;
border: none;
}
`);
export const cssSmallIconButton = styled(basicButton, `
display: flex;
align-items: center;
gap: 4px;
&-frameless {
background-color: transparent;
border: none;
}
`);
export const cssIconButton = styled(bigBasicButton, `
display: flex;
align-items: center;
gap: 4px;
&-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 cssMarkdownRendered = styled('div', `
@@ -615,7 +577,7 @@ export const cssRemoveButton = styled('div', `
cursor: pointer;
}
.${cssFieldEditor.className}-selected > &,
.${cssFieldEditor.className}:hover > & {
.${cssFieldEditor.className}:hover:not(:has(.hover_border:hover)) > & {
display: flex;
}
&-right {
@@ -623,6 +585,124 @@ export const cssRemoveButton = styled('div', `
}
`);
export const cssShareMenu = styled('div', `
color: ${theme.text};
background-color: ${theme.popupBg};
width: min(calc(100% - 16px), 400px);
border-radius: 3px;
padding: 8px;
`);
export const cssShareMenuHeader = styled('div', `
display: flex;
justify-content: flex-end;
`);
export const cssShareMenuBody = styled('div', `
box-sizing: content-box;
display: flex;
flex-direction: column;
row-gap: 32px;
padding: 0px 16px 24px 16px;
min-height: 160px;
`);
export const cssShareMenuCloseButton = styled('div', `
flex-shrink: 0;
border-radius: 4px;
cursor: pointer;
padding: 4px;
--icon-color: ${theme.popupCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
export const cssShareMenuSectionHeading = styled('div', `
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 16px;
`);
export const cssShareMenuHintText = styled('div', `
color: ${theme.lightText};
`);
export const cssShareMenuSpinner = styled('div', `
display: flex;
justify-content: center;
align-items: center;
min-height: inherit;
`);
export const cssShareMenuSectionButtons = styled('div', `
display: flex;
justify-content: flex-end;
margin-top: 16px;
`);
export const cssShareMenuUrlBlock = styled('div', `
display: flex;
background-color: ${theme.inputReadonlyBg};
padding: 8px;
border-radius: 3px;
width: 100%;
margin-top: 16px;
`);
export const cssShareMenuUrl = styled('input', `
background: transparent;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
border: none;
outline: none;
`);
export const cssShareMenuCopyButton = styled(textButton, `
margin-left: 4px;
font-weight: 500;
`);
export const cssShareMenuEmbedFormButton = styled(textButton, `
font-weight: 500;
`);
export const cssShareMenuCodeBlock = styled('div', `
border-radius: 3px;
background-color: ${theme.inputReadonlyBg};
padding: 8px;
`);
export const cssShareMenuCodeBlockButtons = styled('div', `
display: flex;
justify-content: flex-end;
`);
export const cssShareMenuCode = styled('textarea', `
background-color: transparent;
border: none;
border-radius: 3px;
word-break: break-all;
width: 100%;
outline: none;
resize: none;
`);
export const cssFormDisabledOverlay = styled('div', `
background-color: ${theme.widgetBg};
opacity: 0.8;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
`);
export function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {
return [
dom.onKeyDown({