mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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', `
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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', `
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user