(core) New Grist Forms styling and field options

Summary:
 - New styling for forms.
 - New field options for various field types (spinner, checkbox, radio buttons, alignment, sort).
 - Improved alignment of form fields in columns.
 - Support for additional select input keyboard shortcuts (Enter and Backspace).
 - Prevent submitting form on Enter if an input has focus.
 - Fix for changing form field type causing the field to disappear.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4223
This commit is contained in:
George Gevoian
2024-04-10 23:50:30 -07:00
parent 661f1c1804
commit 86062a8c28
35 changed files with 2037 additions and 716 deletions

View File

@@ -5,6 +5,7 @@ import {buildMenu} from 'app/client/components/Forms/Menu';
import {BoxModel} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {makeTestId} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {inlineStyle, not} from 'app/common/gutil';
@@ -13,6 +14,8 @@ import {v4 as uuidv4} from 'uuid';
const testId = makeTestId('test-forms-');
const t = makeT('FormView');
export class ColumnsModel extends BoxModel {
private _columnCount = Computed.create(this, use => use(this.children).length);
@@ -64,7 +67,11 @@ export class ColumnsModel extends BoxModel {
cssPlaceholder(
testId('add'),
icon('Plus'),
dom.on('click', () => this.placeAfterListChild()(Placeholder())),
dom.on('click', async () => {
await this.save(() => {
this.placeAfterListChild()(Placeholder());
});
}),
style.cssColumn.cls('-add-button'),
style.cssColumn.cls('-drag-over', dragHover),
@@ -152,7 +159,7 @@ export class PlaceholderModel extends BoxModel {
buildMenu({
box: this,
insertBox,
customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')],
customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), t('Remove Column'))],
}),
dom.on('contextmenu', (ev) => {
@@ -219,8 +226,8 @@ export class PlaceholderModel extends BoxModel {
return box.parent.replace(box, childBox);
}
function removeColumn() {
box.removeSelf();
async function removeColumn() {
await box.deleteSelf();
}
}
}

View File

@@ -4,10 +4,20 @@ import {FormView} from 'app/client/components/Forms/FormView';
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
import * as css from 'app/client/components/Forms/styles';
import {stopEvent} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization';
import {refRecord} from 'app/client/models/DocModel';
import {
FormNumberFormat,
FormOptionsAlignment,
FormOptionsSortOrder,
FormSelectFormat,
FormTextFormat,
FormToggleFormat,
} from 'app/client/ui/FormAPI';
import {autoGrow} from 'app/client/ui/forms';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {cssCheckboxSquare, cssLabel, squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors} from 'app/client/ui2018/cssVars';
import {cssRadioInput} from 'app/client/ui2018/radio';
import {isBlankValue} from 'app/common/gristTypes';
import {Constructor, not} from 'app/common/gutil';
import {
@@ -22,13 +32,14 @@ import {
MultiHolder,
observable,
Observable,
styled,
toKo
toKo,
} from 'grainjs';
import * as ko from 'knockout';
const testId = makeTestId('test-forms-');
const t = makeT('FormView');
/**
* Container class for all fields.
*/
@@ -86,9 +97,6 @@ export class FieldModel extends BoxModel {
const field = use(this.field);
return Boolean(use(field.widgetOptionsJson.prop('formRequired')));
});
this.required.onWrite(value => {
this.field.peek().widgetOptionsJson.prop('formRequired').setAndSave(value).catch(reportError);
});
this.question.onWrite(value => {
this.field.peek().question.setAndSave(value).catch(reportError);
@@ -152,6 +160,8 @@ export class FieldModel extends BoxModel {
}
export abstract class Question extends Disposable {
protected field = this.model.field;
constructor(public model: FieldModel) {
super();
}
@@ -164,7 +174,7 @@ export abstract class Question extends Disposable {
return css.cssQuestion(
testId('question'),
testType(this.model.colType),
this.renderLabel(props, dom.style('margin-bottom', '5px')),
this.renderLabel(props),
this.renderInput(),
css.cssQuestion.cls('-required', this.model.required),
...args
@@ -223,7 +233,7 @@ export abstract class Question extends Disposable {
css.cssRequiredWrapper(
testId('label'),
// When in edit - hide * and change display from grid to display
css.cssRequiredWrapper.cls('-required', use => Boolean(use(this.model.required) && !use(this.model.edit))),
css.cssRequiredWrapper.cls('-required', use => use(this.model.required) && !use(this.model.edit)),
dom.maybe(props.edit, () => [
element = css.cssEditableLabel(
controller,
@@ -264,36 +274,156 @@ export abstract class Question extends Disposable {
class TextModel extends Question {
private _format = Computed.create<FormTextFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formTextFormat')) ?? 'singleline';
});
private _rowCount = Computed.create<number>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formTextLineCount')) || 3;
});
public renderInput() {
return dom.domComputed(this._format, (format) => {
switch (format) {
case 'singleline': {
return this._renderSingleLineInput();
}
case 'multiline': {
return this._renderMultiLineInput();
}
}
});
}
private _renderSingleLineInput() {
return css.cssInput(
dom.prop('name', u => u(u(this.model.field).colId)),
{disabled: true},
dom.prop('name', u => u(u(this.field).colId)),
{type: 'text', tabIndex: "-1"},
);
}
private _renderMultiLineInput() {
return css.cssTextArea(
dom.prop('name', u => u(u(this.field).colId)),
dom.prop('rows', this._rowCount),
{tabIndex: "-1"},
);
}
}
class ChoiceModel extends Question {
protected choices: Computed<string[]> = Computed.create(this, use => {
// Read choices from field.
const choices = use(use(this.model.field).widgetOptionsJson.prop('choices'));
// Make sure it is an array of strings.
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
return [];
} else {
return choices;
}
class NumericModel extends Question {
private _format = Computed.create<FormNumberFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text';
});
public renderInput(): HTMLElement {
const field = this.model.field;
public renderInput() {
return dom.domComputed(this._format, (format) => {
switch (format) {
case 'text': {
return this._renderTextInput();
}
case 'spinner': {
return this._renderSpinnerInput();
}
}
});
}
private _renderTextInput() {
return css.cssInput(
dom.prop('name', u => u(u(this.field).colId)),
{type: 'text', tabIndex: "-1"},
);
}
private _renderSpinnerInput() {
return css.cssSpinner(observable(''), {});
}
}
class ChoiceModel extends Question {
protected choices: Computed<string[]>;
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
});
private _format = Computed.create<FormSelectFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select';
});
private _sortOrder = Computed.create<FormOptionsSortOrder>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default';
});
constructor(model: FieldModel) {
super(model);
this.choices = Computed.create(this, use => {
// Read choices from field.
const field = use(this.field);
const choices = use(field.widgetOptionsJson.prop('choices'))?.slice() ?? [];
// Make sure it is an array of strings.
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
return [];
} else {
const sort = use(this._sortOrder);
if (sort !== 'default') {
choices.sort((a, b) => a.localeCompare(b));
if (sort === 'descending') {
choices.reverse();
}
}
return choices;
}
});
}
public renderInput() {
return dom('div',
dom.domComputed(this._format, (format) => {
if (format === 'select') {
return this._renderSelectInput();
} else {
return this._renderRadioInput();
}
}),
dom.maybe(use => use(this.choices).length === 0, () => [
css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')),
]),
);
}
private _renderSelectInput() {
return css.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', use => use(use(field).colId)),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})),
dom.prop('name', use => use(use(this.field).colId)),
dom('option',
SELECT_PLACEHOLDER,
{value: ''},
),
dom.forEach(this.choices, (choice) => dom('option',
choice,
{value: choice},
)),
);
}
private _renderRadioInput() {
return css.cssRadioList(
css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
dom.prop('name', use => use(use(this.field).colId)),
dom.forEach(this.choices, (choice) => css.cssRadioLabel(
cssRadioInput({type: 'radio'}),
choice,
)),
);
}
}
@@ -305,21 +435,28 @@ class ChoiceListModel extends ChoiceModel {
});
public renderInput() {
const field = this.model.field;
return dom('div',
const field = this.field;
return css.cssCheckboxList(
css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
dom.prop('name', use => use(use(field).colId)),
dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
choice
css.cssCheckboxLabel.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
cssCheckboxSquare({type: 'checkbox'}),
choice,
)),
dom.maybe(use => use(this._choices).length === 0, () => [
dom('div', 'No choices defined'),
css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')),
]),
);
}
}
class BoolModel extends Question {
private _format = Computed.create<FormToggleFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formToggleFormat')) ?? 'switch';
});
public override buildDom(props: {
edit: Observable<boolean>,
overlay: Observable<boolean>,
@@ -329,22 +466,37 @@ class BoolModel extends Question {
return css.cssQuestion(
testId('question'),
testType(this.model.colType),
cssToggle(
css.cssToggle(
this.renderInput(),
this.renderLabel(props, css.cssLabelInline.cls('')),
),
);
}
public override renderInput() {
const value = Observable.create(this, true);
return dom('div.widget_switch',
return dom.domComputed(this._format, (format) => {
if (format === 'switch') {
return this._renderSwitchInput();
} else {
return this._renderCheckboxInput();
}
});
}
private _renderSwitchInput() {
return css.cssWidgetSwitch(
dom.style('--grist-actual-cell-color', colors.lightGreen.toString()),
dom.cls('switch_on', value),
dom.cls('switch_transition', true),
dom.cls('switch_transition'),
dom('div.switch_slider'),
dom('div.switch_circle'),
);
}
private _renderCheckboxInput() {
return cssLabel(
cssCheckboxSquare({type: 'checkbox'}),
);
}
}
class DateModel extends Question {
@@ -352,8 +504,8 @@ class DateModel extends Question {
return dom('div',
css.cssInput(
dom.prop('name', this.model.colId),
{type: 'date', style: 'margin-right: 5px; width: 100%;'
}),
{type: 'date', style: 'margin-right: 5px;'},
),
);
}
}
@@ -363,7 +515,7 @@ class DateTimeModel extends Question {
return dom('div',
css.cssInput(
dom.prop('name', this.model.colId),
{type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'}
{type: 'datetime-local', style: 'margin-right: 5px;'},
),
dom.style('width', '100%'),
);
@@ -371,19 +523,38 @@ class DateTimeModel extends Question {
}
class RefListModel extends Question {
protected options = this._getOptions();
protected options: Computed<{label: string, value: string}[]>;
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
});
private _sortOrder = Computed.create<FormOptionsSortOrder>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default';
});
constructor(model: FieldModel) {
super(model);
this.options = this._getOptions();
}
public renderInput() {
return dom('div',
return css.cssCheckboxList(
css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
dom.prop('name', this.model.colId),
dom.forEach(this.options, (option) => css.cssCheckboxLabel(
squareCheckbox(observable(false)),
option.label,
)),
dom.maybe(use => use(this.options).length === 0, () => [
dom('div', 'No values in show column of referenced table'),
css.cssWarningMessage(
css.cssWarningIcon('Warning'),
t('No values in show column of referenced table'),
),
]),
) as HTMLElement;
);
}
private _getOptions() {
@@ -394,39 +565,83 @@ class RefListModel extends Question {
const colId = Computed.create(this, use => {
const dispColumnIdObs = use(use(this.model.column).visibleColModel);
return use(dispColumnIdObs.colId);
return use(dispColumnIdObs.colId) || 'id';
});
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
return Computed.create(this, use => {
return use(observer)
const sort = use(this._sortOrder);
const values = 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.
.map(([id, value]) => ({label: String(value), value: String(id)}));
if (sort !== 'default') {
values.sort((a, b) => a.label.localeCompare(b.label));
if (sort === 'descending') {
values.reverse();
}
}
return values.slice(0, 30);
});
}
}
class RefModel extends RefListModel {
private _format = Computed.create<FormSelectFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select';
});
public renderInput() {
return dom('div',
dom.domComputed(this._format, (format) => {
if (format === 'select') {
return this._renderSelectInput();
} else {
return this._renderRadioInput();
}
}),
dom.maybe(use => use(this.options).length === 0, () => [
css.cssWarningMessage(
css.cssWarningIcon('Warning'),
t('No values in show column of referenced table'),
),
]),
);
}
private _renderSelectInput() {
return css.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.model.colId),
dom('option', SELECT_PLACEHOLDER, {value: ''}),
dom.forEach(this.options, ({label, value}) => dom('option', label, {value})),
dom('option',
SELECT_PLACEHOLDER,
{value: ''},
),
dom.forEach(this.options, ({label, value}) => dom('option',
label,
{value},
)),
);
}
private _renderRadioInput() {
return css.cssRadioList(
css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
dom.prop('name', use => use(use(this.field).colId)),
dom.forEach(this.options, ({label, value}) => css.cssRadioLabel(
cssRadioInput({type: 'radio'}),
label,
)),
);
}
}
// TODO: decide which one we need and implement rest.
const AnyModel = TextModel;
const NumericModel = TextModel;
const IntModel = TextModel;
const AttachmentsModel = TextModel;
// Attachments are not currently supported.
const AttachmentsModel = TextModel;
function fieldConstructor(type: string): Constructor<Question> {
switch (type) {
@@ -436,7 +651,7 @@ function fieldConstructor(type: string): Constructor<Question> {
case 'ChoiceList': return ChoiceListModel;
case 'Date': return DateModel;
case 'DateTime': return DateTimeModel;
case 'Int': return IntModel;
case 'Int': return NumericModel;
case 'Numeric': return NumericModel;
case 'Ref': return RefModel;
case 'RefList': return RefListModel;
@@ -451,12 +666,3 @@ function fieldConstructor(type: string): Constructor<Question> {
function testType(value: BindableValue<string>) {
return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
}
const cssToggle = styled('div', `
display: grid;
align-items: center;
grid-template-columns: auto 1fr;
gap: 8px;
padding: 4px 0px;
--grist-actual-cell-color: ${colors.lightGreen};
`);

View File

@@ -1,25 +1,119 @@
import {fromKoSave} from 'app/client/lib/fromKoSave';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
import {fieldWithDefault} from 'app/client/models/modelUtil';
import {FormOptionsAlignment, FormOptionsSortOrder, FormSelectFormat} from 'app/client/ui/FormAPI';
import {
cssLabel,
cssRow,
cssSeparator,
} from 'app/client/ui/RightPanelStyles';
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {testId} from 'app/client/ui2018/cssVars';
import {Disposable} from 'grainjs';
import {select} from 'app/client/ui2018/menus';
import {Disposable, dom, makeTestId} from 'grainjs';
const t = makeT('FormConfig');
export class FieldRulesConfig extends Disposable {
const testId = makeTestId('test-form-');
export class FormSelectConfig extends Disposable {
constructor(private _field: ViewFieldRec) {
super();
}
public buildDom() {
const requiredField: KoSaveableObservable<boolean> = this._field.widgetOptionsJson.prop('formRequired');
const format = fieldWithDefault<FormSelectFormat>(
this._field.widgetOptionsJson.prop('formSelectFormat'),
'select'
);
return [
cssLabel(t('Field Format')),
cssRow(
buttonSelect(
fromKoSave(format),
[
{value: 'select', label: t('Select')},
{value: 'radio', label: t('Radio')},
],
testId('field-format'),
),
),
dom.maybe(use => use(format) === 'radio', () => dom.create(FormOptionsAlignmentConfig, this._field)),
];
}
}
export class FormOptionsAlignmentConfig extends Disposable {
constructor(private _field: ViewFieldRec) {
super();
}
public buildDom() {
const alignment = fieldWithDefault<FormOptionsAlignment>(
this._field.widgetOptionsJson.prop('formOptionsAlignment'),
'vertical'
);
return [
cssLabel(t('Options Alignment')),
cssRow(
select(
fromKoSave(alignment),
[
{value: 'vertical', label: t('Vertical')},
{value: 'horizontal', label: t('Horizontal')},
],
{defaultLabel: t('Vertical')}
),
),
];
}
}
export class FormOptionsSortConfig extends Disposable {
constructor(private _field: ViewFieldRec) {
super();
}
public buildDom() {
const optionsSortOrder = fieldWithDefault<FormOptionsSortOrder>(
this._field.widgetOptionsJson.prop('formOptionsSortOrder'),
'default'
);
return [
cssLabel(t('Options Sort Order')),
cssRow(
select(
fromKoSave(optionsSortOrder),
[
{value: 'default', label: t('Default')},
{value: 'ascending', label: t('Ascending')},
{value: 'descending', label: t('Descending')},
],
{defaultLabel: t('Default')}
),
),
];
}
}
export class FormFieldRulesConfig extends Disposable {
constructor(private _field: ViewFieldRec) {
super();
}
public buildDom() {
const requiredField = fieldWithDefault<boolean>(
this._field.widgetOptionsJson.prop('formRequired'),
false
);
return [
cssSeparator(),
cssLabel(t('Field rules')),
cssLabel(t('Field Rules')),
cssRow(labeledSquareCheckbox(
fromKoSave(requiredField),
t('Required field'),

View File

@@ -16,6 +16,7 @@ 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 {jsonObservable, SaveableObjObservable} from 'app/client/models/modelUtil';
import {ShareRec} from 'app/client/models/entities/ShareRec';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
@@ -55,7 +56,8 @@ export class FormView extends Disposable {
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _formFields: Computed<ViewFieldRec[]>;
private _autoLayout: Computed<FormLayoutNode>;
private _layoutSpec: SaveableObjObservable<FormLayoutNode>;
private _layout: Computed<FormLayoutNode>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
@@ -67,7 +69,7 @@ export class FormView extends Disposable {
private _showPublishedMessage: Observable<boolean>;
private _isOwner: boolean;
private _openingForm: Observable<boolean>;
private _formElement: HTMLElement;
private _formEditorBodyElement: HTMLElement;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
@@ -134,28 +136,30 @@ export class FormView extends Disposable {
this._formFields = Computed.create(this, use => {
const fields = use(use(this.viewSection.viewFields).getObservable());
return fields.filter(f => use(use(f.column).isFormCol));
return fields.filter(f => {
const column = use(f.column);
return (
use(column.pureType) !== 'Attachments' &&
!(use(column.isRealFormula) && !use(column.colId).startsWith('gristHelper_Transform'))
);
});
});
this._autoLayout = Computed.create(this, use => {
this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => {
return layoutSpec ?? buildDefaultFormLayout(this._formFields.get());
});
this._layout = Computed.create(this, use => {
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'); }
const layoutSpec = use(this._layoutSpec);
const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.id())));
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
return patchedLayout;
}
return patchedLayout;
});
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise<void>) => {
await this.bundle(async () => {
// If the box is autogenerated we need to save it first.
if (!this.viewSection.layoutSpecObj.peek()?.id) {
await this.save();
}
if (clb) {
await clb();
}
@@ -163,7 +167,7 @@ export class FormView extends Disposable {
});
}, this));
this._autoLayout.addListener((v) => {
this._layout.addListener((v) => {
if (this._saving) {
console.warn('Layout changed while saving');
return;
@@ -421,9 +425,9 @@ export class FormView extends Disposable {
public buildDom() {
return style.cssFormView(
testId('editor'),
style.cssFormEditBody(
this._formEditorBodyElement = style.cssFormEditBody(
style.cssFormContainer(
this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
dom('div', dom.forEach(this._root.children, (child) => {
if (!child) {
return dom('div', 'Empty node');
}
@@ -433,9 +437,9 @@ export class FormView extends Disposable {
}
return element;
})),
this._buildPublisher(),
),
),
this._buildPublisher(),
dom.on('click', () => this.selectedBox.set(null)),
dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
);
@@ -481,7 +485,7 @@ export class FormView extends Disposable {
// If nothing has changed, don't bother.
if (isEqual(newVersion, this._savedLayout)) { return; }
this._savedLayout = newVersion;
await this.viewSection.layoutSpecObj.setAndSave(newVersion);
await this._layoutSpec.setAndSave(newVersion);
} finally {
this._saving = false;
}
@@ -861,17 +865,17 @@ export class FormView extends Disposable {
);
}
private _getSectionCount() {
return [...this._root.filter(box => box.type === 'Section')].length;
}
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.
// Form height.
this._formEditorBodyElement.scrollHeight +
// Minus "+" button height in each section.
(-32 * this._getSectionCount()) +
// Plus form footer height (visible only in the preview and published form).
64
);
}
@@ -902,30 +906,6 @@ export class FormView extends Disposable {
});
}
/**
* Generates a form template based on the fields in the view section.
*/
private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode {
const boxes: FormLayoutNode[] = fields.map(f => {
return {
id: uuidv4(),
type: 'Field',
leaf: f.id(),
};
});
const section = components.Section(...boxes);
return {
id: uuidv4(),
type: 'Layout',
children: [
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
section,
{id: uuidv4(), type: 'Submit'},
],
};
}
private async _resetForm() {
this.selectedBox.set(null);
await this.gristDoc.docData.bundleActions('Reset form', async () => {
@@ -951,11 +931,35 @@ export class FormView extends Disposable {
]);
const fields = this.viewSection.viewFields().all().slice(0, 9);
await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields));
await this._layoutSpec.setAndSave(buildDefaultFormLayout(fields));
});
}
}
/**
* Generates a default form layout based on the fields in the view section.
*/
export function buildDefaultFormLayout(fields: ViewFieldRec[]): FormLayoutNode {
const boxes: FormLayoutNode[] = fields.map(f => {
return {
id: uuidv4(),
type: 'Field',
leaf: f.id(),
};
});
const section = components.Section(...boxes);
return {
id: uuidv4(),
type: 'Layout',
children: [
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
section,
{id: uuidv4(), type: 'Submit'},
],
};
}
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
defaults(FormView.prototype, BaseView.prototype);
Object.assign(FormView.prototype, BackboneEvents);

View File

@@ -1,7 +1,17 @@
import {FormLayoutNode, FormLayoutNodeType} from 'app/client/components/FormRenderer';
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 {MaybePromise} from 'app/plugin/gutil';
import {
bundleChanges,
Computed,
Disposable,
dom,
IDomArgs,
MutableObsArray,
obsArray,
Observable,
} from 'grainjs';
type Callback = () => Promise<void>;
@@ -186,7 +196,7 @@ export abstract class BoxModel extends Disposable {
return this._props.hasOwnProperty(name);
}
public async save(before?: () => Promise<void>): Promise<void> {
public async save(before?: () => MaybePromise<void>): Promise<void> {
if (!this.parent) { throw new Error('Cannot save detached box'); }
return this.parent.save(before);
}

View File

@@ -62,7 +62,6 @@ export class SectionModel extends BoxModel {
),
)
)},
style.cssSectionEditor.cls(''),
);
}

View File

@@ -1,3 +1,4 @@
import * as css from "app/client/components/FormRendererCss";
import { BoxModel } from "app/client/components/Forms/Model";
import { makeTestId } from "app/client/lib/domUtils";
import { bigPrimaryButton } from "app/client/ui2018/buttons";
@@ -9,8 +10,14 @@ export class SubmitModel extends BoxModel {
const text = this.view.viewSection.layoutSpecObj.prop('submitText');
return dom(
"div",
{ style: "text-align: center; margin-top: 20px;" },
bigPrimaryButton(dom.text(use => use(text) || 'Submit'), testId("submit"))
css.error(testId("error")),
css.submitButtons(
bigPrimaryButton(
dom.text(use => use(text) || 'Submit'),
{ disabled: true },
testId("submit"),
),
),
);
}
}

View File

@@ -1,8 +1,10 @@
import {textarea} from 'app/client/ui/inputs';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons';
import {cssLabel} from 'app/client/ui2018/checkbox';
import {colors, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {numericSpinner} from 'app/client/widgets/NumericSpinner';
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
import {marked} from 'marked';
@@ -14,7 +16,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', `
align-items: center;
justify-content: space-between;
position: relative;
background-color: ${theme.leftPanelBg};
overflow: auto;
min-height: 100%;
width: 100%;
@@ -22,7 +23,6 @@ export const cssFormView = styled('div.flexauto.flexvbox', `
export const cssFormContainer = styled('div', `
background-color: ${theme.mainPanelBg};
border: 1px solid ${theme.modalBorderDark};
color: ${theme.text};
width: 600px;
align-self: center;
@@ -31,10 +31,8 @@ export const cssFormContainer = styled('div', `
display: flex;
flex-direction: column;
max-width: calc(100% - 32px);
padding-top: 20px;
padding-left: 48px;
padding-right: 48px;
gap: 8px;
line-height: 1.42857143;
`);
export const cssFieldEditor = styled('div.hover_border.field_editor', `
@@ -47,6 +45,11 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', `
margin-bottom: 4px;
--hover-visible: hidden;
transition: transform 0.2s ease-in-out;
&-Section {
outline: 1px solid ${theme.modalBorderDark};
margin-bottom: 24px;
padding: 16px;
}
&:hover:not(:has(.hover_border:hover),&-cut) {
--hover-visible: visible;
outline: 1px solid ${theme.controlPrimaryBg};
@@ -78,37 +81,40 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', `
}
`);
export const cssSectionEditor = styled('div', `
border-radius: 3px;
padding: 16px;
border: 1px solid ${theme.modalBorderDark};
`);
export const cssSection = styled('div', `
position: relative;
color: ${theme.text};
margin: 0px auto;
min-height: 50px;
.${cssFormView.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
min-height: auto;
`);
export const cssCheckboxList = styled('div', `
display: flex;
flex-direction: column;
gap: 8px;
&-horizontal {
flex-direction: row;
flex-wrap: wrap;
column-gap: 16px;
}
`);
export const cssCheckboxLabel = styled('label', `
font-size: 15px;
export const cssCheckboxLabel = styled(cssLabel, `
font-size: 13px;
line-height: 16px;
font-weight: normal;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
margin: 0px;
margin-bottom: 8px;
overflow-wrap: anywhere;
`);
export const cssRadioList = cssCheckboxList;
export const cssRadioLabel = cssCheckboxLabel;
export function textbox(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
return dom('input',
dom.prop('value', u => u(obs) || ''),
@@ -118,11 +124,14 @@ export function textbox(obs: Observable<string|undefined>, ...args: DomElementAr
}
export const cssQuestion = styled('div', `
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
`);
export const cssRequiredWrapper = styled('div', `
margin-bottom: 8px;
margin: 8px 0px;
min-height: 16px;
&-required {
display: grid;
@@ -148,7 +157,7 @@ export const cssRenderedLabel = styled('div', `
min-height: 16px;
color: ${theme.mediumText};
font-size: 11px;
font-size: 13px;
line-height: 16px;
font-weight: 700;
white-space: pre-wrap;
@@ -186,17 +195,9 @@ export const cssEditableLabel = styled(textarea, `
`);
export const cssLabelInline = styled('div', `
margin-bottom: 0px;
& .${cssRenderedLabel.className} {
color: ${theme.mediumText};
font-size: 15px;
font-weight: normal;
}
& .${cssEditableLabel.className} {
color: ${colors.darkText};
font-size: 15px;
font-weight: normal;
}
line-height: 16px;
margin: 0px;
overflow-wrap: anywhere;
`);
export const cssDesc = styled('div', `
@@ -211,15 +212,19 @@ export const cssDesc = styled('div', `
`);
export const cssInput = styled('input', `
background-color: ${theme.inputDisabledBg};
background-color: ${theme.inputBg};
font-size: inherit;
height: 27px;
height: 29px;
padding: 4px 8px;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
outline: none;
pointer-events: none;
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
}
&-invalid {
color: ${theme.inputInvalid};
}
@@ -228,10 +233,37 @@ export const cssInput = styled('input', `
}
`);
export const cssTextArea = styled('textarea', `
background-color: ${theme.inputBg};
font-size: inherit;
min-height: 29px;
padding: 4px 8px;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
outline: none;
pointer-events: none;
resize: none;
width: 100%;
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
}
`);
export const cssSpinner = styled(numericSpinner, `
height: 29px;
&-hidden {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
}
`);
export const cssSelect = styled('select', `
flex: auto;
width: 100%;
background-color: ${theme.inputDisabledBg};
background-color: ${theme.inputBg};
font-size: inherit;
height: 27px;
padding: 4px 8px;
@@ -241,8 +273,34 @@ export const cssSelect = styled('select', `
pointer-events: none;
`);
export const cssFieldEditorContent = styled('div', `
export const cssToggle = styled('div', `
display: grid;
grid-template-columns: auto 1fr;
margin-top: 12px;
gap: 8px;
--grist-actual-cell-color: ${colors.lightGreen};
`);
export const cssWidgetSwitch = styled('div.widget_switch', `
&-hidden {
opacity: 0.6;
}
`);
export const cssWarningMessage = styled('div', `
margin-top: 8px;
display: flex;
align-items: center;
column-gap: 8px;
`);
export const cssWarningIcon = styled(icon, `
--icon-color: ${colors.warning};
flex-shrink: 0;
`);
export const cssFieldEditorContent = styled('div', `
height: 100%;
`);
export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
@@ -253,10 +311,6 @@ export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
.${cssFieldEditor.className}-selected > & {
opacity: 1;
}
.${cssFormView.className}-preview & {
display: none;
}
`);
export const cssPlusButton = styled('div', `
@@ -288,22 +342,12 @@ export const cssPlusIcon = styled(icon, `
export const cssColumns = styled('div', `
--css-columns-count: 2;
display: grid;
grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px;
gap: 8px;
padding: 8px 4px;
.${cssFormView.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
grid-template-columns: repeat(var(--css-columns-count), 1fr);
min-height: auto;
}
`);
export const cssColumn = styled('div', `
position: relative;
&-empty, &-add-button {
@@ -336,21 +380,6 @@ export const cssColumn = styled('div', `
&-drag-over {
outline: 2px dashed ${theme.controlPrimaryBg};
}
&-add-button {
}
.${cssFormView.className}-preview &-add-button {
display: none;
}
.${cssFormView.className}-preview &-empty {
background: transparent;
border-radius: unset;
padding: 0px;
min-height: auto;
border: 0px;
}
`);
export const cssButtonGroup = styled('div', `
@@ -511,16 +540,13 @@ export const cssPreview = styled('iframe', `
`);
export const cssSwitcher = styled('div', `
flex-shrink: 0;
margin-top: 24px;
border-top: 1px solid ${theme.modalBorder};
margin-left: -48px;
margin-right: -48px;
border-top: 1px solid ${theme.menuBorder};
width: 100%;
`);
export const cssSwitcherMessage = styled('div', `
display: flex;
padding: 0px 16px 0px 16px;
padding: 8px 16px;
`);
export const cssSwitcherMessageBody = styled('div', `
@@ -528,7 +554,7 @@ export const cssSwitcherMessageBody = styled('div', `
display: flex;
justify-content: center;
align-items: center;
padding: 10px 32px;
padding: 8px 16px;
`);
export const cssSwitcherMessageDismissButton = styled('div', `
@@ -551,8 +577,7 @@ export const cssParagraph = styled('div', `
export const cssFormEditBody = styled('div', `
width: 100%;
overflow: auto;
padding-top: 52px;
padding-bottom: 24px;
padding: 20px;
`);
export const cssRemoveButton = styled('div', `