(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
pull/936/head
George Gevoian 4 weeks ago
parent 661f1c1804
commit 86062a8c28

@ -149,10 +149,14 @@ class SectionRenderer extends FormRenderer {
class ColumnsRenderer extends FormRenderer {
public render() {
return css.columns(
{style: `--grist-columns-count: ${this.children.length || 1}`},
{style: `--grist-columns-count: ${this._getColumnsCount()}`},
this.children.map((child) => child.render()),
);
}
private _getColumnsCount() {
return this.children.length || 1;
}
}
class SubmitRenderer extends FormRenderer {
@ -180,22 +184,7 @@ class SubmitRenderer extends FormRenderer {
type: 'submit',
value: this.context.rootLayoutNode.submitText || 'Submit',
},
dom.on('click', () => {
// Make sure that all choice or reference lists that are required have at least one option selected.
const lists = document.querySelectorAll('.grist-checkbox-list.required:not(:has(input:checked))');
Array.from(lists).forEach(function(list) {
// If the form has at least one checkbox, make it required.
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.setAttribute('required', 'required');
});
// All other required choice or reference lists with at least one option selected are no longer required.
const checkedLists = document.querySelectorAll('.grist-checkbox-list.required:has(input:checked)');
Array.from(checkedLists).forEach(function(list) {
const firstCheckbox = list.querySelector('input[type="checkbox"]');
firstCheckbox?.removeAttribute('required');
});
}),
dom.on('click', () => validateRequiredLists()),
)
),
),
@ -228,7 +217,7 @@ class FieldRenderer extends FormRenderer {
}
public render() {
return css.field(this.renderer.render());
return this.renderer.render();
}
public reset() {
@ -267,41 +256,120 @@ abstract class BaseFieldRenderer extends Disposable {
}
class TextRenderer extends BaseFieldRenderer {
protected type = 'text';
private _value = Observable.create(this, '');
protected inputType = 'text';
private _format = this.field.options.formTextFormat ?? 'singleline';
private _lineCount = String(this.field.options.formTextLineCount || 3);
private _value = Observable.create<string>(this, '');
public input() {
return dom('input',
if (this._format === 'singleline') {
return this._renderSingleLineInput();
} else {
return this._renderMultiLineInput();
}
}
public resetInput(): void {
this._value.setAndTrigger('');
}
private _renderSingleLineInput() {
return css.textInput(
{
type: this.type,
type: this.inputType,
name: this.name(),
required: this.field.options.formRequired,
},
dom.prop('value', this._value),
preventSubmitOnEnter(),
);
}
private _renderMultiLineInput() {
return css.textarea(
{
name: this.name(),
required: this.field.options.formRequired,
rows: this._lineCount,
},
dom.prop('value', this._value),
dom.on('input', (_e, elem) => this._value.set(elem.value)),
);
}
}
class NumericRenderer extends BaseFieldRenderer {
protected inputType = 'text';
private _format = this.field.options.formNumberFormat ?? 'text';
private _value = Observable.create<string>(this, '');
private _spinnerValue = Observable.create<number|''>(this, '');
public input() {
if (this._format === 'text') {
return this._renderTextInput();
} else {
return this._renderSpinnerInput();
}
}
public resetInput(): void {
this._value.set('');
this._value.setAndTrigger('');
this._spinnerValue.setAndTrigger('');
}
private _renderTextInput() {
return css.textInput(
{
type: this.inputType,
name: this.name(),
required: this.field.options.formRequired,
},
dom.prop('value', this._value),
preventSubmitOnEnter(),
);
}
private _renderSpinnerInput() {
return css.spinner(
this._spinnerValue,
{
setValueOnInput: true,
inputArgs: [
{
name: this.name(),
required: this.field.options.formRequired,
},
preventSubmitOnEnter(),
],
}
);
}
}
class DateRenderer extends TextRenderer {
protected type = 'date';
protected inputType = 'date';
}
class DateTimeRenderer extends TextRenderer {
protected type = 'datetime-local';
protected inputType = 'datetime-local';
}
export const SELECT_PLACEHOLDER = 'Select...';
class ChoiceRenderer extends BaseFieldRenderer {
protected value = Observable.create<string>(this, '');
protected value: Observable<string>;
private _choices: string[];
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
private _format = this.field.options.formSelectFormat ?? 'select';
private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
private _radioButtons: MutableObsArray<{
label: string;
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
@ -310,24 +378,59 @@ class ChoiceRenderer extends BaseFieldRenderer {
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
this._choices = [];
} else {
const sortOrder = this.field.options.formOptionsSortOrder ?? 'default';
if (sortOrder !== 'default') {
choices.sort((a, b) => String(a).localeCompare(String(b)));
if (sortOrder === 'descending') {
choices.reverse();
}
}
// Support for 1000 choices. TODO: make limit dynamic.
this._choices = choices.slice(0, 1000);
}
this.value = Observable.create<string>(this, '');
this._radioButtons.set(this._choices.map(choice => ({
label: String(choice),
checked: Observable.create(this, null),
})));
}
public input() {
if (this._format === 'select') {
return this._renderSelectInput();
} else {
return this._renderRadioInput();
}
}
public resetInput() {
this.value.set('');
this._radioButtons.get().forEach(radioButton => {
radioButton.checked.set(null);
});
}
private _renderSelectInput() {
return css.hybridSelect(
this._selectElement = css.select(
{name: this.name(), required: this.field.options.formRequired},
dom.prop('value', this.value),
dom.on('input', (_e, elem) => this.value.set(elem.value)),
dom('option', {value: ''}, SELECT_PLACEHOLDER),
this._choices.map((choice) => dom('option', {value: choice}, choice)),
this._choices.map((choice) => dom('option',
{value: choice},
dom.prop('selected', use => use(this.value) === choice),
choice
)),
dom.onKeyDown({
Enter$: (ev) => this._maybeOpenSearchSelect(ev),
' $': (ev) => this._maybeOpenSearchSelect(ev),
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
Backspace$: () => this.value.set(''),
}),
preventSubmitOnEnter(),
),
dom.maybe(use => !use(isXSmallScreenObs()), () =>
css.searchSelect(
@ -359,8 +462,29 @@ class ChoiceRenderer extends BaseFieldRenderer {
);
}
public resetInput(): void {
this.value.set('');
private _renderRadioInput() {
const required = this.field.options.formRequired;
return css.radioList(
css.radioList.cls('-horizontal', this._alignment === 'horizontal'),
dom.cls('grist-radio-list'),
dom.cls('required', Boolean(required)),
{name: this.name(), required},
dom.forEach(this._radioButtons, (radioButton) =>
css.radio(
dom('input',
dom.prop('checked', radioButton.checked),
dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)),
{
type: 'radio',
name: `${this.name()}`,
value: radioButton.label,
},
preventSubmitOnEnter(),
),
dom('span', radioButton.label),
)
),
);
}
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
@ -375,8 +499,11 @@ class ChoiceRenderer extends BaseFieldRenderer {
}
class BoolRenderer extends BaseFieldRenderer {
protected inputType = 'checkbox';
protected checked = Observable.create<boolean>(this, false);
private _format = this.field.options.formToggleFormat ?? 'switch';
public render() {
return css.field(
dom('div', this.input()),
@ -384,16 +511,29 @@ class BoolRenderer extends BaseFieldRenderer {
}
public input() {
return css.toggle(
if (this._format === 'switch') {
return this._renderSwitchInput();
} else {
return this._renderCheckboxInput();
}
}
public resetInput(): void {
this.checked.set(false);
}
private _renderSwitchInput() {
return css.toggleSwitch(
dom('input',
dom.prop('checked', this.checked),
dom.prop('value', use => use(this.checked) ? '1' : '0'),
dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
{
type: 'checkbox',
type: this.inputType,
name: this.name(),
value: '1',
required: this.field.options.formRequired,
},
preventSubmitOnEnter(),
),
css.gristSwitch(
css.gristSwitchSlider(),
@ -406,8 +546,24 @@ class BoolRenderer extends BaseFieldRenderer {
);
}
public resetInput(): void {
this.checked.set(false);
private _renderCheckboxInput() {
return css.toggle(
dom('input',
dom.prop('checked', this.checked),
dom.prop('value', use => use(this.checked) ? '1' : '0'),
dom.on('change', (_e, elem) => this.checked.set(elem.checked)),
{
type: this.inputType,
name: this.name(),
required: this.field.options.formRequired,
},
preventSubmitOnEnter(),
),
css.toggleLabel(
css.label.cls('-required', Boolean(this.field.options.formRequired)),
this.field.question,
),
);
}
}
@ -417,6 +573,8 @@ class ChoiceListRenderer extends BaseFieldRenderer {
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
@ -424,6 +582,13 @@ class ChoiceListRenderer extends BaseFieldRenderer {
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
choices = [];
} else {
const sortOrder = this.field.options.formOptionsSortOrder ?? 'default';
if (sortOrder !== 'default') {
choices.sort((a, b) => String(a).localeCompare(String(b)));
if (sortOrder === 'descending') {
choices.reverse();
}
}
// Support for 30 choices. TODO: make limit dynamic.
choices = choices.slice(0, 30);
}
@ -437,6 +602,7 @@ class ChoiceListRenderer extends BaseFieldRenderer {
public input() {
const required = this.field.options.formRequired;
return css.checkboxList(
css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'),
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(), required},
@ -449,7 +615,8 @@ class ChoiceListRenderer extends BaseFieldRenderer {
type: 'checkbox',
name: `${this.name()}[]`,
value: checkbox.label,
}
},
preventSubmitOnEnter(),
),
dom('span', checkbox.label),
)
@ -471,12 +638,20 @@ class RefListRenderer extends BaseFieldRenderer {
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
const references = this.field.refValues ?? [];
// Sort by the second value, which is the display value.
references.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
const sortOrder = this.field.options.formOptionsSortOrder;
if (sortOrder !== 'default') {
// Sort by the second value, which is the display value.
references.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
if (sortOrder === 'descending') {
references.reverse();
}
}
// Support for 30 choices. TODO: make limit dynamic.
references.splice(30);
this.checkboxes.set(references.map(reference => ({
@ -488,6 +663,7 @@ class RefListRenderer extends BaseFieldRenderer {
public input() {
const required = this.field.options.formRequired;
return css.checkboxList(
css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'),
dom.cls('grist-checkbox-list'),
dom.cls('required', Boolean(required)),
{name: this.name(), required},
@ -501,7 +677,8 @@ class RefListRenderer extends BaseFieldRenderer {
'data-grist-type': this.field.type,
name: `${this.name()}[]`,
value: checkbox.value,
}
},
preventSubmitOnEnter(),
),
dom('span', checkbox.label),
)
@ -518,15 +695,58 @@ class RefListRenderer extends BaseFieldRenderer {
class RefRenderer extends BaseFieldRenderer {
protected value = Observable.create(this, '');
private _format = this.field.options.formSelectFormat ?? 'select';
private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
private _choices: [number|string, CellValue][];
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
private _radioButtons: MutableObsArray<{
label: string;
value: string;
checked: Observable<string|null>
}> = this.autoDispose(obsArray());
public constructor(field: FormField, context: FormRendererContext) {
super(field, context);
public input() {
const choices: [number|string, CellValue][] = this.field.refValues ?? [];
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
const sortOrder = this.field.options.formOptionsSortOrder ?? 'default';
if (sortOrder !== 'default') {
// Sort by the second value, which is the display value.
choices.sort((a, b) => String(a[1]).localeCompare(String(b[1])));
if (sortOrder === 'descending') {
choices.reverse();
}
}
// Support for 1000 choices. TODO: make limit dynamic.
choices.splice(1000);
this._choices = choices.slice(0, 1000);
this.value = Observable.create<string>(this, '');
this._radioButtons.set(this._choices.map(reference => ({
label: String(reference[1]),
value: String(reference[0]),
checked: Observable.create(this, null),
})));
}
public input() {
if (this._format === 'select') {
return this._renderSelectInput();
} else {
return this._renderRadioInput();
}
}
public resetInput(): void {
this.value.set('');
this._radioButtons.get().forEach(radioButton => {
radioButton.checked.set(null);
});
}
private _renderSelectInput() {
return css.hybridSelect(
this._selectElement = css.select(
{
@ -534,27 +754,37 @@ class RefRenderer extends BaseFieldRenderer {
'data-grist-type': this.field.type,
required: this.field.options.formRequired,
},
dom.prop('value', this.value),
dom.on('input', (_e, elem) => this.value.set(elem.value)),
dom('option', {value: ''}, SELECT_PLACEHOLDER),
choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]))),
dom('option',
{value: ''},
SELECT_PLACEHOLDER,
dom.prop('selected', use => use(this.value) === ''),
),
this._choices.map((choice) => dom('option',
{value: String(choice[0])},
String(choice[1]),
dom.prop('selected', use => use(this.value) === String(choice[0])),
)),
dom.onKeyDown({
Enter$: (ev) => this._maybeOpenSearchSelect(ev),
' $': (ev) => this._maybeOpenSearchSelect(ev),
ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev),
ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev),
Backspace$: () => this.value.set(''),
}),
preventSubmitOnEnter(),
),
dom.maybe(use => !use(isXSmallScreenObs()), () =>
css.searchSelect(
dom('div', dom.text(use => {
const choice = choices.find((c) => String(c[0]) === use(this.value));
const choice = this._choices.find((c) => String(c[0]) === use(this.value));
return String(choice?.[1] || SELECT_PLACEHOLDER);
})),
dropdownWithSearch<string>({
action: (value) => this.value.set(value),
options: () => [
{label: SELECT_PLACEHOLDER, value: '', placeholder: true},
...choices.map((choice) => ({
...this._choices.map((choice) => ({
label: String(choice[1]),
value: String(choice[0]),
}),
@ -577,8 +807,29 @@ class RefRenderer extends BaseFieldRenderer {
);
}
public resetInput(): void {
this.value.set('');
private _renderRadioInput() {
const required = this.field.options.formRequired;
return css.radioList(
css.radioList.cls('-horizontal', this._alignment === 'horizontal'),
dom.cls('grist-radio-list'),
dom.cls('required', Boolean(required)),
{name: this.name(), required, 'data-grist-type': this.field.type},
dom.forEach(this._radioButtons, (radioButton) =>
css.radio(
dom('input',
dom.prop('checked', radioButton.checked),
dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)),
{
type: 'radio',
name: `${this.name()}`,
value: radioButton.value,
},
preventSubmitOnEnter(),
),
dom('span', radioButton.label),
)
),
);
}
private _maybeOpenSearchSelect(ev: KeyboardEvent) {
@ -594,6 +845,8 @@ class RefRenderer extends BaseFieldRenderer {
const FieldRenderers = {
'Text': TextRenderer,
'Numeric': NumericRenderer,
'Int': NumericRenderer,
'Choice': ChoiceRenderer,
'Bool': BoolRenderer,
'ChoiceList': ChoiceListRenderer,
@ -616,3 +869,36 @@ const FormRenderers = {
'Separator': ParagraphRenderer,
'Header': ParagraphRenderer,
};
function preventSubmitOnEnter() {
return dom.onKeyDown({Enter$: (ev) => ev.preventDefault()});
}
/**
* Validates the required attribute of checkbox and radio lists, such as those
* used by Choice, Choice List, Reference, and Reference List fields.
*
* Since lists of checkboxes and radios don't natively support a required attribute, we
* simulate it by marking the first checkbox/radio of each required list as being a
* required input. Then, we make another pass and unmark all required checkbox/radio
* inputs if they belong to a list where at least one checkbox/radio is checked. If any
* inputs in a required are left as required, HTML validations that are triggered when
* submitting a form will catch them and prevent the submission.
*/
function validateRequiredLists() {
for (const type of ['checkbox', 'radio']) {
const requiredLists = document
.querySelectorAll(`.grist-${type}-list.required:not(:has(input:checked))`);
Array.from(requiredLists).forEach(function(list) {
const firstOption = list.querySelector(`input[type="${type}"]`);
firstOption?.setAttribute('required', 'required');
});
const requiredListsWithCheckedOption = document
.querySelectorAll(`.grist-${type}-list.required:has(input:checked`);
Array.from(requiredListsWithCheckedOption).forEach(function(list) {
const firstOption = list.querySelector(`input[type="${type}"]`);
firstOption?.removeAttribute('required');
});
}
}

@ -1,5 +1,6 @@
import {colors, mediaXSmall, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {numericSpinner} from 'app/client/widgets/NumericSpinner';
import {styled} from 'grainjs';
export const label = styled('div', `
@ -26,20 +27,23 @@ export const section = styled('div', `
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
padding: 24px;
margin-top: 24px;
margin-top: 12px;
margin-bottom: 24px;
& > div + div {
margin-top: 16px;
margin-top: 8px;
margin-bottom: 12px;
}
`);
export const columns = styled('div', `
display: grid;
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
gap: 4px;
gap: 16px;
`);
export const submitButtons = styled('div', `
margin-top: 16px;
display: flex;
justify-content: center;
column-gap: 8px;
@ -100,32 +104,13 @@ export const submitButton = styled('div', `
export const field = styled('div', `
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
& input[type="text"],
& input[type="date"],
& input[type="datetime-local"],
& input[type="number"] {
height: 27px;
padding: 4px 8px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
outline-color: ${vars.primaryBgHover};
}
& input[type="text"] {
font-size: 13px;
line-height: inherit;
width: 100%;
color: ${colors.dark};
background-color: ${colors.light};
}
& input[type="datetime-local"],
& input[type="date"] {
width: 100%;
line-height: inherit;
}
& input[type="checkbox"] {
-webkit-appearance: none;
-moz-appearance: none;
margin: 0;
padding: 0;
flex-shrink: 0;
display: inline-block;
@ -195,19 +180,80 @@ export const field = styled('div', `
`);
export const error = styled('div', `
margin-top: 16px;
text-align: center;
color: ${colors.error};
min-height: 22px;
`);
export const textInput = styled('input', `
color: ${colors.dark};
background-color: ${colors.light};
height: 29px;
width: 100%;
font-size: 13px;
line-height: inherit;
padding: 4px 8px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
outline-color: ${vars.primaryBgHover};
`);
export const textarea = styled('textarea', `
display: block;
color: ${colors.dark};
background-color: ${colors.light};
min-height: 29px;
width: 100%;
font-size: 13px;
line-height: inherit;
padding: 4px 8px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
outline-color: ${vars.primaryBgHover};
resize: none;
`);
export const spinner = styled(numericSpinner, `
& input {
height: 29px;
border: none;
font-size: 13px;
line-height: inherit;
}
&:focus-within {
outline: 2px solid ${vars.primaryBgHover};
}
`);
export const toggle = styled('label', `
position: relative;
cursor: pointer;
display: inline-flex;
align-items: center;
margin-top: 8px;
&:hover {
--color: ${colors.hover};
}
`);
export const toggleSwitch = styled(toggle, `
cursor: pointer;
& input[type='checkbox'] {
margin: 0;
position: absolute;
top: 1px;
left: 4px;
}
& input[type='checkbox'],
& input[type='checkbox']::before,
& input[type='checkbox']::after {
height: 1px;
width: 1px;
}
& input[type='checkbox']:focus {
outline: none;
}
& input[type='checkbox']:focus {
outline: none;
@ -220,6 +266,8 @@ export const toggle = styled('label', `
export const toggleLabel = styled('span', `
font-size: 13px;
font-weight: 700;
line-height: 16px;
overflow-wrap: anywhere;
`);
export const gristSwitchSlider = styled('div', `
@ -233,10 +281,6 @@ export const gristSwitchSlider = styled('div', `
border-radius: 17px;
-webkit-transition: background-color .4s;
transition: background-color .4s;
&:hover {
box-shadow: 0 0 1px #2196F3;
}
`);
export const gristSwitchCircle = styled('div', `
@ -277,19 +321,67 @@ export const gristSwitch = styled('div', `
`);
export const checkboxList = styled('div', `
display: flex;
display: inline-flex;
flex-direction: column;
gap: 4px;
gap: 8px;
&-horizontal {
flex-direction: row;
flex-wrap: wrap;
column-gap: 16px;
}
`);
export const checkbox = styled('label', `
display: flex;
font-size: 13px;
line-height: 16px;
gap: 8px;
overflow-wrap: anywhere;
& input {
margin: 0px !important;
}
&:hover {
--color: ${colors.hover};
}
`);
export const radioList = checkboxList;
export const radio = styled('label', `
position: relative;
display: inline-flex;
gap: 8px;
font-size: 13px;
line-height: 16px;
font-weight: normal;
min-width: 0px;
outline-color: ${vars.primaryBgHover};
overflow-wrap: anywhere;
& input {
flex-shrink: 0;
appearance: none;
width: 16px;
height: 16px;
margin: 0px;
border-radius: 50%;
background-clip: content-box;
border: 1px solid ${colors.darkGrey};
background-color: transparent;
outline-color: ${vars.primaryBgHover};
}
& input:hover {
border: 1px solid ${colors.hover};
}
& input:checked {
padding: 2px;
background-color: ${vars.primaryBg};
border: 1px solid ${vars.primaryBg};
}
`);
export const hybridSelect = styled('div', `
position: relative;
`);
@ -303,7 +395,7 @@ export const select = styled('select', `
outline: none;
background: white;
line-height: inherit;
height: 27px;
height: 29px;
flex: auto;
width: 100%;
@ -323,11 +415,11 @@ export const searchSelect = styled('div', `
position: relative;
padding: 4px 8px;
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
outline: 1px solid ${colors.darkGrey};
font-size: 13px;
background: white;
line-height: inherit;
height: 27px;
height: 29px;
flex: auto;
width: 100%;

@ -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();
}
}
}

@ -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.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 NumericModel extends Question {
private _format = Computed.create<FormNumberFormat>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text';
});
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.model.field).colId)),
{disabled: true},
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[]> = 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;
}
protected choices: Computed<string[]>;
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
const field = use(this.field);
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
});
public renderInput(): HTMLElement {
const field = this.model.field;
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};
`);

@ -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 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: KoSaveableObservable<boolean> = this._field.widgetOptionsJson.prop('formRequired');
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'),

@ -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._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => {
return layoutSpec ?? buildDefaultFormLayout(this._formFields.get());
});
this._autoLayout = Computed.create(this, use => {
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);

@ -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);
}

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

@ -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"),
),
),
);
}
}

@ -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', `

@ -14,6 +14,7 @@ import {DocComm} from 'app/client/components/DocComm';
import * as DocConfigTab from 'app/client/components/DocConfigTab';
import {Drafts} from "app/client/components/Drafts";
import {EditorMonitor} from "app/client/components/EditorMonitor";
import {buildDefaultFormLayout} from 'app/client/components/Forms/FormView';
import GridView from 'app/client/components/GridView';
import {importFromFile, selectAndImport} from 'app/client/components/Importer';
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
@ -946,6 +947,9 @@ export class GristDoc extends DisposableWithEvents {
if (val.type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
}
if (val.type === 'form') {
await this._setDefaultFormLayoutSpec(result.sectionRef);
}
await this.saveLink(val.link, result.sectionRef);
return result;
}
@ -962,42 +966,48 @@ export class GristDoc extends DisposableWithEvents {
},
});
if (val.table === 'New Table') {
const name = await this._promptForName();
if (name === undefined) {
return;
}
let newViewId: IDocPage;
if (val.type === WidgetType.Table) {
const result = await this.docData.sendAction(['AddEmptyTable', name]);
newViewId = result.views[0].id;
let viewRef: IDocPage;
let sectionRef: number | undefined;
await this.docData.bundleActions('Add new page', async () => {
if (val.table === 'New Table') {
const name = await this._promptForName();
if (name === undefined) {
return;
}
if (val.type === WidgetType.Table) {
const result = await this.docData.sendAction(['AddEmptyTable', name]);
viewRef = result.views[0].id;
} else {
// This will create a new table and page.
const result = await this.docData.sendAction(
['CreateViewSection', /* new table */0, 0, val.type, null, name]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
}
} else {
// This will create a new table and page.
const result = await this.docData.sendAction(
['CreateViewSection', /* new table */0, 0, val.type, null, name]
);
newViewId = result.viewRef;
}
await this.openDocPage(newViewId);
} else {
let result: any;
await this.docData.bundleActions(`Add new page`, async () => {
result = await this.docData.sendAction(
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
);
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
if (val.type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
await this._ensureOneNumericSeries(sectionRef!);
}
});
await this.openDocPage(result.viewRef);
}
if (val.type === 'form') {
await this._setDefaultFormLayoutSpec(sectionRef!);
}
});
await this.openDocPage(viewRef!);
if (sectionRef) {
// The newly-added section should be given focus.
this.viewModel.activeSectionId(result.sectionRef);
this.viewModel.activeSectionId(sectionRef);
}
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
if (AttachedCustomWidgets.guard(val.type)) {
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
}
}
@ -1425,6 +1435,8 @@ export class GristDoc extends DisposableWithEvents {
const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);
const holder = Holder.create(owner);
const listener = (tab: TableModel) => {
if (tab.tableData.tableId === '') { return; }
// Now subscribe to any data change in that table.
const subs = MultiHolder.create(holder);
subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle));
@ -1921,6 +1933,12 @@ export class GristDoc extends DisposableWithEvents {
}
}
private async _setDefaultFormLayoutSpec(viewSectionId: number) {
const viewSection = this.docModel.viewSections.getRowModel(viewSectionId);
const viewFields = viewSection.viewFields.peek().peek();
await viewSection.layoutSpecObj.setAndSave(buildDefaultFormLayout(viewFields));
}
private _handleTriggerQueueOverflowMessage() {
this.listenTo(this, 'webhookOverflowError', (err: any) => {
this.app.topAppModel.notifier.createNotification({

@ -41,13 +41,52 @@ export interface FormField {
refValues: [number, CellValue][] | null;
}
interface FormFieldOptions {
/** True if the field is required to submit the form. */
formRequired?: boolean;
/** Populated with a list of options. Only set if the field `type` is a Choice/Reference Liste. */
export interface FormFieldOptions {
/** Choices for a Choice or Choice List field. */
choices?: string[];
/** Text or Any field format. Defaults to `"singleline"`. */
formTextFormat?: FormTextFormat;
/** Number of lines/rows for the `"multiline"` option of `formTextFormat`. Defaults to `3`. */
formTextLineCount?: number;
/** Numeric or Int field format. Defaults to `"text"`. */
formNumberFormat?: FormNumberFormat;
/** Toggle field format. Defaults to `"switch"`. */
formToggleFormat?: FormToggleFormat;
/** Choice or Reference field format. Defaults to `"select"`. */
formSelectFormat?: FormSelectFormat;
/**
* Field options alignment.
*
* Only applicable to Choice List and Reference List fields, and Choice and Reference fields
* when `formSelectFormat` is `"radio"`.
*
* Defaults to `"vertical"`.
*/
formOptionsAlignment?: FormOptionsAlignment;
/**
* Field options sort order.
*
* Only applicable to Choice, Choice List, Reference, and Reference List fields.
*
* Defaults to `"default"`.
*/
formOptionsSortOrder?: FormOptionsSortOrder;
/** True if the field is required. Defaults to `false`. */
formRequired?: boolean;
}
export type FormTextFormat = 'singleline' | 'multiline';
export type FormNumberFormat = 'text' | 'spinner';
export type FormToggleFormat = 'switch' | 'checkbox';
export type FormSelectFormat = 'select' | 'radio';
export type FormOptionsAlignment = 'vertical' | 'horizontal';
export type FormOptionsSortOrder = 'default' | 'ascending' | 'descending';
export interface FormAPI {
getForm(options: GetFormOptions): Promise<Form>;
createRecord(options: CreateRecordOptions): Promise<void>;

@ -1,36 +1,144 @@
import {makeT} from 'app/client/lib/localization';
import * as css from 'app/client/ui/FormPagesCss';
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {commonUrls} from 'app/common/gristUrls';
import {DomContents, makeTestId} from 'grainjs';
import {DomContents, DomElementArg, styled} from 'grainjs';
const t = makeT('FormContainer');
const testId = makeTestId('test-form-');
export function buildFormContainer(buildBody: () => DomContents) {
return css.formContainer(
css.form(
css.formBody(
export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {
return cssFormMessagePage(
cssFormMessage(
cssFormMessageBody(
buildBody(),
),
css.formFooter(
css.poweredByGrist(
css.poweredByGristLink(
{href: commonUrls.forms, target: '_blank'},
t('Powered by'),
css.gristLogo(),
)
),
css.buildForm(
css.buildFormLink(
{href: commonUrls.forms, target: '_blank'},
t('Build your own form'),
icon('Expand'),
),
),
cssFormMessageFooter(
buildFormFooter(),
),
),
testId('container'),
...args,
);
}
export function buildFormFooter() {
return [
cssPoweredByGrist(
cssPoweredByGristLink(
{href: commonUrls.forms, target: '_blank'},
t('Powered by'),
cssGristLogo(),
)
),
cssBuildForm(
cssBuildFormLink(
{href: commonUrls.forms, target: '_blank'},
t('Build your own form'),
icon('Expand'),
),
),
];
}
export const cssFormMessageImageContainer = styled('div', `
margin-top: 28px;
display: flex;
justify-content: center;
`);
export const cssFormMessageImage = styled('img', `
height: 100%;
width: 100%;
`);
export const cssFormMessageText = styled('div', `
color: ${colors.dark};
text-align: center;
font-weight: 600;
font-size: 16px;
line-height: 24px;
margin-top: 32px;
margin-bottom: 24px;
`);
const cssFormMessagePage = styled('div', `
padding: 16px;
`);
const cssFormMessage = styled('div', `
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
const cssFormMessageBody = styled('div', `
width: 100%;
padding: 20px 48px 20px 48px;
@media ${mediaSmall} {
& {
padding: 20px;
}
}
`);
const cssFormMessageFooter = styled('div', `
border-top: 1px solid ${colors.darkGrey};
padding: 8px 16px;
width: 100%;
`);
const cssPoweredByGrist = styled('div', `
color: ${colors.darkText};
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0px 10px;
`);
const cssPoweredByGristLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: ${colors.darkText};
text-decoration: none;
`);
const cssGristLogo = styled('div', `
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(img/logo-grist.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
`);
const cssBuildForm = styled('div', `
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
`);
const cssBuildFormLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 16px;
text-decoration-line: underline;
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
`);

@ -1,9 +1,13 @@
import {makeT} from 'app/client/lib/localization';
import {buildFormContainer} from 'app/client/ui/FormContainer';
import * as css from 'app/client/ui/FormPagesCss';
import {
buildFormMessagePage,
cssFormMessageImage,
cssFormMessageImageContainer,
cssFormMessageText,
} from 'app/client/ui/FormContainer';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, makeTestId} from 'grainjs';
import {Disposable, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-form-');
@ -16,11 +20,20 @@ export class FormErrorPage extends Disposable {
}
public buildDom() {
return buildFormContainer(() => [
css.formErrorMessageImageContainer(css.formErrorMessageImage({
src: 'img/form-error.svg',
})),
css.formMessageText(this._message, testId('error-text')),
]);
return buildFormMessagePage(() => [
cssFormErrorMessageImageContainer(
cssFormErrorMessageImage({src: 'img/form-error.svg'}),
),
cssFormMessageText(this._message, testId('error-page-text')),
], testId('error-page'));
}
}
const cssFormErrorMessageImageContainer = styled(cssFormMessageImageContainer, `
height: 281px;
`);
const cssFormErrorMessageImage = styled(cssFormMessageImage, `
max-height: 281px;
max-width: 250px;
`);

@ -2,18 +2,19 @@ import {FormRenderer} from 'app/client/components/FormRenderer';
import {handleSubmit, TypedFormData} from 'app/client/lib/formUtils';
import {makeT} from 'app/client/lib/localization';
import {FormModel, FormModelImpl} from 'app/client/models/FormModel';
import {buildFormContainer} from 'app/client/ui/FormContainer';
import {buildFormFooter} from 'app/client/ui/FormContainer';
import {FormErrorPage} from 'app/client/ui/FormErrorPage';
import * as css from 'app/client/ui/FormPagesCss';
import {FormSuccessPage} from 'app/client/ui/FormSuccessPage';
import {colors} from 'app/client/ui2018/cssVars';
import {ApiError} from 'app/common/ApiError';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, dom, Observable, styled, subscribe} from 'grainjs';
import {Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs';
const t = makeT('FormPage');
const testId = makeTestId('test-form-');
export class FormPage extends Disposable {
private readonly _model: FormModel = new FormModelImpl();
private readonly _error = Observable.create<string|null>(this, null);
@ -30,7 +31,7 @@ export class FormPage extends Disposable {
}
public buildDom() {
return css.pageContainer(
return cssPageContainer(
dom.domComputed(use => {
const error = use(this._model.error);
if (error) { return dom.create(FormErrorPage, error); }
@ -38,12 +39,12 @@ export class FormPage extends Disposable {
const submitted = use(this._model.submitted);
if (submitted) { return dom.create(FormSuccessPage, this._model); }
return this._buildFormDom();
return this._buildFormPageDom();
}),
);
}
private _buildFormDom() {
private _buildFormPageDom() {
return dom.domComputed(use => {
const form = use(this._model.form);
const rootLayoutNode = use(this._model.formLayout);
@ -56,16 +57,24 @@ export class FormPage extends Disposable {
error: this._error,
});
return buildFormContainer(() =>
return dom('div',
cssForm(
dom.autoDispose(formRenderer),
formRenderer.render(),
handleSubmit(this._model.submitting,
(_formData, formElement) => this._handleFormSubmit(formElement),
() => this._handleFormSubmitSuccess(),
(e) => this._handleFormError(e),
cssFormBody(
cssFormContent(
dom.autoDispose(formRenderer),
formRenderer.render(),
handleSubmit(this._model.submitting,
(_formData, formElement) => this._handleFormSubmit(formElement),
() => this._handleFormSubmitSuccess(),
(e) => this._handleFormError(e),
),
),
),
cssFormFooter(
buildFormFooter(),
),
),
testId('page'),
);
});
}
@ -101,22 +110,40 @@ export class FormPage extends Disposable {
}
}
// TODO: see if we can move the rest of this to `FormRenderer.ts`.
const cssForm = styled('form', `
const cssPageContainer = styled('div', `
height: 100%;
width: 100%;
padding: 20px;
overflow: auto;
`);
const cssForm = styled('div', `
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
const cssFormBody = styled('div', `
width: 100%;
`);
// TODO: break up and move to `FormRendererCss.ts`.
const cssFormContent = styled('form', `
color: ${colors.dark};
font-size: 15px;
line-height: 1.42857143;
& > div + div {
margin-top: 16px;
}
& h1,
& h2,
& h3,
& h4,
& h5,
& h6 {
margin: 4px 0px;
margin: 8px 0px 12px 0px;
font-weight: normal;
}
& h1 {
@ -149,3 +176,8 @@ const cssForm = styled('form', `
margin: 4px 0px;
}
`);
const cssFormFooter = styled('div', `
padding: 8px 16px;
width: 100%;
`);

@ -1,139 +0,0 @@
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
import {styled} from 'grainjs';
export const pageContainer = styled('div', `
background-color: ${colors.lightGrey};
height: 100%;
width: 100%;
padding: 52px 0px 52px 0px;
overflow: auto;
@media ${mediaSmall} {
& {
padding: 20px 0px 20px 0px;
}
}
`);
export const formContainer = styled('div', `
padding-left: 16px;
padding-right: 16px;
`);
export const form = styled('div', `
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
max-width: 600px;
margin: 0px auto;
`);
export const formBody = styled('div', `
width: 100%;
padding: 20px 48px 20px 48px;
@media ${mediaSmall} {
& {
padding: 20px;
}
}
`);
const formMessageImageContainer = styled('div', `
margin-top: 28px;
display: flex;
justify-content: center;
`);
export const formErrorMessageImageContainer = styled(formMessageImageContainer, `
height: 281px;
`);
export const formSuccessMessageImageContainer = styled(formMessageImageContainer, `
height: 215px;
`);
export const formMessageImage = styled('img', `
height: 100%;
width: 100%;
`);
export const formErrorMessageImage = styled(formMessageImage, `
max-height: 281px;
max-width: 250px;
`);
export const formSuccessMessageImage = styled(formMessageImage, `
max-height: 215px;
max-width: 250px;
`);
export const formMessageText = styled('div', `
color: ${colors.dark};
text-align: center;
font-weight: 600;
font-size: 16px;
line-height: 24px;
margin-top: 32px;
margin-bottom: 24px;
`);
export const formFooter = styled('div', `
border-top: 1px solid ${colors.darkGrey};
padding: 8px 16px;
width: 100%;
`);
export const poweredByGrist = styled('div', `
color: ${colors.darkText};
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0px 10px;
`);
export const poweredByGristLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: ${colors.darkText};
text-decoration: none;
`);
export const buildForm = styled('div', `
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
`);
export const buildFormLink = styled('a', `
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 16px;
text-decoration-line: underline;
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
`);
export const gristLogo = styled('div', `
width: 58px;
height: 20.416px;
flex-shrink: 0;
background: url(img/logo-grist.png);
background-position: 0 0;
background-size: contain;
background-color: transparent;
background-repeat: no-repeat;
margin-top: 3px;
`);

@ -1,7 +1,11 @@
import {makeT} from 'app/client/lib/localization';
import {FormModel } from 'app/client/models/FormModel';
import {buildFormContainer} from 'app/client/ui/FormContainer';
import * as css from 'app/client/ui/FormPagesCss';
import {FormModel} from 'app/client/models/FormModel';
import {
buildFormMessagePage,
cssFormMessageImage,
cssFormMessageImageContainer,
cssFormMessageText,
} from 'app/client/ui/FormContainer';
import {vars} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
@ -28,20 +32,20 @@ export class FormSuccessPage extends Disposable {
}
public buildDom() {
return buildFormContainer(() => [
css.formSuccessMessageImageContainer(css.formSuccessMessageImage({
src: 'img/form-success.svg',
})),
css.formMessageText(dom.text(this._successText), testId('success-text')),
return buildFormMessagePage(() => [
cssFormSuccessMessageImageContainer(
cssFormSuccessMessageImage({src: 'img/form-success.svg'}),
),
cssFormMessageText(dom.text(this._successText), testId('success-page-text')),
dom.maybe(this._showNewResponseButton, () =>
cssFormButtons(
cssFormNewResponseButton(
'Submit new response',
t('Submit new response'),
dom.on('click', () => this._handleClickNewResponseButton()),
),
)
),
]);
], testId('success-page'));
}
private async _handleClickNewResponseButton() {
@ -49,6 +53,15 @@ export class FormSuccessPage extends Disposable {
}
}
const cssFormSuccessMessageImageContainer = styled(cssFormMessageImageContainer, `
height: 215px;
`);
const cssFormSuccessMessageImage = styled(cssFormMessageImage, `
max-height: 215px;
max-width: 250px;
`);
const cssFormButtons = styled('div', `
display: flex;
justify-content: center;

@ -1,5 +1,6 @@
import {theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {numericSpinner} from 'app/client/widgets/NumericSpinner';
import {styled} from 'grainjs';
export const cssIcon = styled(icon, `
@ -89,3 +90,7 @@ export const cssPinButton = styled('div', `
background-color: ${theme.hover};
}
`);
export const cssNumericSpinner = styled(numericSpinner, `
height: 28px;
`);

@ -23,6 +23,7 @@ export const cssLabel = styled('label', `
display: inline-flex;
min-width: 0px;
margin-bottom: 0px;
flex-shrink: 0;
outline: none;
user-select: none;

@ -0,0 +1,25 @@
import {theme} from 'app/client/ui2018/cssVars';
import {styled} from 'grainjs';
export const cssRadioInput = styled('input', `
appearance: none;
width: 16px;
height: 16px;
margin: 0px !important;
border-radius: 50%;
background-clip: content-box;
border: 1px solid ${theme.checkboxBorder};
background-color: ${theme.checkboxBg};
flex-shrink: 0;
&:hover {
border: 1px solid ${theme.checkboxBorderHover};
}
&:disabled {
background-color: 1px solid ${theme.checkboxDisabledBg};
}
&:checked {
padding: 2px;
background-color: ${theme.controlPrimaryBg};
border: 1px solid ${theme.controlPrimaryBg};
}
`);

@ -1,13 +1,18 @@
import {
FormFieldRulesConfig,
FormOptionsAlignmentConfig,
FormOptionsSortConfig,
} from 'app/client/components/Forms/FormConfig';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {testId} from 'app/client/ui2018/cssVars';
import {
ChoiceOptionsByName,
ChoiceTextBox,
} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {CellValue} from 'app/common/DocActions';
import {decodeObject} from 'app/plugin/objtypes';
import {dom, styled} from 'grainjs';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
/**
* ChoiceListCell - A cell that renders a list of choice tokens.
@ -49,6 +54,15 @@ export class ChoiceListCell extends ChoiceTextBox {
}),
);
}
public buildFormConfigDom() {
return [
this.buildChoicesConfigDom(),
dom.create(FormOptionsAlignmentConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}
}
export const cssChoiceList = styled('div', `

@ -1,3 +1,8 @@
import {
FormFieldRulesConfig,
FormOptionsSortConfig,
FormSelectConfig,
} from 'app/client/components/Forms/FormConfig';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
@ -76,7 +81,7 @@ export class ChoiceTextBox extends NTextBox {
public buildConfigDom() {
return [
super.buildConfigDom(),
this._buildChoicesConfigDom(),
this.buildChoicesConfigDom(),
];
}
@ -86,14 +91,16 @@ export class ChoiceTextBox extends NTextBox {
public buildFormConfigDom() {
return [
this._buildChoicesConfigDom(),
super.buildFormConfigDom(),
this.buildChoicesConfigDom(),
dom.create(FormSelectConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}
public buildFormTransformConfigDom() {
return [
this._buildChoicesConfigDom(),
this.buildChoicesConfigDom(),
];
}
@ -113,7 +120,7 @@ export class ChoiceTextBox extends NTextBox {
return this.field.config.updateChoices(renames, options);
}
private _buildChoicesConfigDom() {
protected buildChoicesConfigDom() {
const disabled = Computed.create(null,
use => use(this.field.disableModify)
|| use(use(this.field.column).disableEditData)

@ -6,7 +6,7 @@ var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var AbstractWidget = require('./AbstractWidget');
const {FieldRulesConfig} = require('app/client/components/Forms/FormConfig');
const {FormFieldRulesConfig} = require('app/client/components/Forms/FormConfig');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles');
@ -82,7 +82,7 @@ DateTextBox.prototype.buildTransformConfigDom = function() {
DateTextBox.prototype.buildFormConfigDom = function() {
return [
gdom.create(FieldRulesConfig, this.field),
gdom.create(FormFieldRulesConfig, this.field),
];
};

@ -108,12 +108,11 @@ export class FieldBuilder extends Disposable {
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
private readonly _docModel: DocModel;
private readonly _readonly: Computed<boolean>;
private readonly _isForm: ko.Computed<boolean>;
private readonly _comments: ko.Computed<boolean>;
private readonly _showRefConfigPopup: ko.Observable<boolean>;
private readonly _isEditorActive = Observable.create(this, false);
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
super();
@ -128,9 +127,13 @@ export class FieldBuilder extends Disposable {
this._readonly = Computed.create(this, (use) =>
use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview));
this._isForm = this.autoDispose(ko.computed(() => {
return this.field.viewSection().widgetType() === WidgetType.Form;
}));
// Observable with a list of available types.
this._availableTypes = Computed.create(this, (use) => {
const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form;
const isForm = use(this._isForm);
const isFormula = use(this.origColumn.isFormula);
const types: Array<IOptionFull<string>> = [];
_.each(UserType.typeDefs, (def: any, key: string|number) => {
@ -201,8 +204,11 @@ export class FieldBuilder extends Disposable {
// Returns the constructor for the widget, and only notifies subscribers on changes.
this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => {
return UserTypeImpl.getWidgetConstructor(this.options().widget,
this._readOnlyPureType());
if (this._isForm()) {
return UserTypeImpl.getFormWidgetConstructor(this.options().widget, this._readOnlyPureType());
} else {
return UserTypeImpl.getWidgetConstructor(this.options().widget, this._readOnlyPureType());
}
})).onlyNotifyUnequal());
// Computed builder for the widget.

@ -1,14 +1,16 @@
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { fromKoSave } from 'app/client/lib/fromKoSave';
import { makeT } from 'app/client/lib/localization';
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { cssRow } from 'app/client/ui/RightPanelStyles';
import { alignmentSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
import { fieldWithDefault } from 'app/client/models/modelUtil';
import { FormTextFormat } from 'app/client/ui/FormAPI';
import { cssLabel, cssNumericSpinner, cssRow } from 'app/client/ui/RightPanelStyles';
import { alignmentSelect, buttonSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
import { testId } from 'app/client/ui2018/cssVars';
import { makeLinks } from 'app/client/ui2018/links';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { Computed, dom, DomContents, fromKo, Observable } from 'grainjs';
import { makeT } from 'app/client/lib/localization';
const t = makeT('NTextBox');
@ -60,8 +62,42 @@ export class NTextBox extends NewAbstractWidget {
}
public buildFormConfigDom(): DomContents {
const format = fieldWithDefault<FormTextFormat>(
this.field.widgetOptionsJson.prop('formTextFormat'),
'singleline'
);
const lineCount = fieldWithDefault<number|"">(
this.field.widgetOptionsJson.prop('formTextLineCount'),
''
);
return [
dom.create(FieldRulesConfig, this.field),
cssLabel(t('Field Format')),
cssRow(
buttonSelect(
fromKoSave(format),
[
{value: 'singleline', label: t('Single line')},
{value: 'multiline', label: t('Multi line')},
],
testId('tb-form-field-format'),
),
),
dom.maybe(use => use(format) === 'multiline', () =>
cssRow(
cssNumericSpinner(
fromKo(lineCount),
{
label: t('Lines'),
defaultValue: 3,
minValue: 1,
maxValue: 99,
save: async (val) => lineCount.setAndSave((val && Math.floor(val)) ?? ''),
},
),
),
),
dom.create(FormFieldRulesConfig, this.field),
];
}

@ -0,0 +1,172 @@
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {clamp, numberOrDefault} from 'app/common/gutil';
import {MaybePromise} from 'app/plugin/gutil';
import {BindableValue, dom, DomElementArg, IDomArgs, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-numeric-spinner-');
export interface NumericSpinnerOptions {
/** Defaults to `false`. */
setValueOnInput?: boolean;
label?: string;
defaultValue?: number | Observable<number>;
/** No minimum if unset. */
minValue?: number;
/** No maximum if unset. */
maxValue?: number;
disabled?: BindableValue<boolean>;
inputArgs?: IDomArgs<HTMLInputElement>;
/** Called on blur and spinner button click. */
save?: (val?: number) => MaybePromise<void>,
}
export function numericSpinner(
value: Observable<number | ''>,
options: NumericSpinnerOptions = {},
...args: DomElementArg[]
) {
const {
setValueOnInput = false,
label,
defaultValue,
minValue = Number.NEGATIVE_INFINITY,
maxValue = Number.POSITIVE_INFINITY,
disabled,
inputArgs = [],
save,
} = options;
const getDefaultValue = () => {
if (defaultValue === undefined) {
return 0;
} else if (typeof defaultValue === 'number') {
return defaultValue;
} else {
return defaultValue.get();
}
};
let inputElement: HTMLInputElement;
const shiftValue = async (delta: 1 | -1, opts: {saveValue?: boolean} = {}) => {
const {saveValue} = opts;
const currentValue = numberOrDefault(inputElement.value, getDefaultValue());
const newValue = clamp(Math.floor(currentValue + delta), minValue, maxValue);
if (setValueOnInput) { value.set(newValue); }
if (saveValue) { await save?.(newValue); }
return newValue;
};
const incrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(1, opts);
const decrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(-1, opts);
return cssNumericSpinner(
disabled ? cssNumericSpinner.cls('-disabled', disabled) : null,
label ? cssNumLabel(label) : null,
inputElement = cssNumInput(
{type: 'number'},
dom.prop('value', value),
defaultValue !== undefined ? dom.prop('placeholder', defaultValue) : null,
dom.onKeyDown({
ArrowUp: async (_ev, elem) => { elem.value = String(await incrementValue()); },
ArrowDown: async (_ev, elem) => { elem.value = String(await decrementValue()); },
Enter$: async (_ev, elem) => save && elem.blur(),
}),
!setValueOnInput ? null : dom.on('input', (_ev, elem) => {
value.set(Number.parseFloat(elem.value));
}),
!save ? null : dom.on('blur', async () => {
let newValue = numberOrDefault(inputElement.value, undefined);
if (newValue !== undefined) { newValue = clamp(newValue, minValue, maxValue); }
await save(newValue);
}),
dom.on('focus', (_ev, elem) => elem.select()),
...inputArgs,
),
cssSpinner(
cssSpinnerBtn(
cssSpinnerTop('DropdownUp'),
dom.on('click', async () => incrementValue({saveValue: true})),
testId('increment'),
),
cssSpinnerBtn(
cssSpinnerBottom('Dropdown'),
dom.on('click', async () => decrementValue({saveValue: true})),
testId('decrement'),
),
),
...args
);
}
const cssNumericSpinner = styled('div', `
position: relative;
flex: auto;
font-weight: normal;
display: flex;
align-items: center;
outline: 1px solid ${theme.inputBorder};
background-color: ${theme.inputBg};
border-radius: 3px;
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssNumLabel = styled('div', `
color: ${theme.lightText};
flex-shrink: 0;
padding-left: 8px;
pointer-events: none;
`);
const cssNumInput = styled('input', `
flex-grow: 1;
padding: 4px 32px 4px 8px;
width: 100%;
text-align: right;
appearance: none;
color: ${theme.inputFg};
background-color: transparent;
border: none;
outline: none;
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
`);
const cssSpinner = styled('div', `
position: absolute;
right: 8px;
width: 16px;
height: 100%;
display: flex;
flex-direction: column;
`);
const cssSpinnerBtn = styled('div', `
--icon-color: ${theme.controlSecondaryFg};
flex: 1 1 0px;
min-height: 0px;
position: relative;
cursor: pointer;
overflow: hidden;
&:hover {
--icon-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSpinnerTop = styled(icon, `
position: absolute;
top: 0px;
`);
const cssSpinnerBottom = styled(icon, `
position: absolute;
bottom: 0px;
`);

@ -1,23 +1,25 @@
/**
* See app/common/NumberFormat for description of options we support.
*/
import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig';
import {fromKoSave} from 'app/client/lib/fromKoSave';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {fieldWithDefault} from 'app/client/models/modelUtil';
import {FormNumberFormat} from 'app/client/ui/FormAPI';
import {cssLabel, cssNumericSpinner, cssRow} from 'app/client/ui/RightPanelStyles';
import {buttonSelect, cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {clamp} from 'app/common/gutil';
import {numberOrDefault} from 'app/common/gutil';
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
import {BindableValue, Computed, dom, DomContents, DomElementArg,
fromKo, MultiHolder, Observable, styled} from 'grainjs';
import {Computed, dom, DomContents, fromKo, MultiHolder, styled} from 'grainjs';
import * as LocaleCurrency from 'locale-currency';
const t = makeT('NumericTextBox');
const modeOptions: Array<ISelectorOption<NumMode>> = [
{value: 'currency', label: '$'},
{value: 'decimal', label: ','},
@ -75,9 +77,10 @@ export class NumericTextBox extends NTextBox {
};
// Prepare setters for the UI elements.
// Min/max fraction digits may range from 0 to 20; other values are invalid.
const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20));
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20));
// If defined, `val` will be a floating point number between 0 and 20; make sure it's
// saved as an integer.
const setMinDecimals = (val?: number) => setSave('decimals', val && Math.floor(val));
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && Math.floor(val));
// Mode and Sign behave as toggles: clicking a selected on deselects it.
const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
@ -105,16 +108,56 @@ export class NumericTextBox extends NTextBox {
]),
cssLabel(t('Decimals')),
cssRow(
decimals('min', minDecimals, defaultMin, setMinDecimals, disabled, testId('numeric-min-decimals')),
decimals('max', maxDecimals, defaultMax, setMaxDecimals, disabled, testId('numeric-max-decimals')),
cssNumericSpinner(
minDecimals,
{
label: t('min'),
minValue: 0,
maxValue: 20,
defaultValue: defaultMin,
disabled,
save: setMinDecimals,
},
testId('numeric-min-decimals'),
),
cssNumericSpinner(
maxDecimals,
{
label: t('max'),
minValue: 0,
maxValue: 20,
defaultValue: defaultMax,
disabled,
save: setMaxDecimals,
},
testId('numeric-max-decimals'),
),
),
];
}
}
function numberOrDefault<T>(value: unknown, def: T): number | T {
return value !== null && value !== undefined ? Number(value) : def;
public buildFormConfigDom(): DomContents {
const format = fieldWithDefault<FormNumberFormat>(
this.field.widgetOptionsJson.prop('formNumberFormat'),
'text'
);
return [
cssLabel(t('Field Format')),
cssRow(
buttonSelect(
fromKoSave(format),
[
{value: 'text', label: t('Text')},
{value: 'spinner', label: t('Spinner')},
],
testId('numeric-form-field-format'),
),
),
dom.create(FormFieldRulesConfig, this.field),
];
}
}
// Helper used by setSave() above to reset some properties when switching modes.
@ -126,107 +169,6 @@ function updateOptions(prop: keyof NumberFormatOptions, value: unknown): Partial
return {};
}
function decimals(
label: string,
value: Observable<number | ''>,
defaultValue: Observable<number>,
setFunc: (val?: number) => void,
disabled: BindableValue<boolean>,
...args: DomElementArg[]
) {
return cssDecimalsBox(
cssDecimalsBox.cls('-disabled', disabled),
cssNumLabel(label),
cssNumInput({type: 'text', size: '2', min: '0'},
dom.prop('value', value),
dom.prop('placeholder', defaultValue),
dom.on('change', (ev, elem) => {
const newVal = parseInt(elem.value, 10);
// Set value explicitly before its updated via setFunc; this way the value reflects the
// observable in the case the observable is left unchanged (e.g. because of clamping).
elem.value = String(value.get());
setFunc(Number.isNaN(newVal) ? undefined : newVal);
elem.blur();
}),
dom.on('focus', (ev, elem) => elem.select()),
),
cssSpinner(
cssSpinnerBtn(cssSpinnerTop('DropdownUp'),
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) + 1))),
cssSpinnerBtn(cssSpinnerBottom('Dropdown'),
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) - 1))),
),
...args
);
}
const cssDecimalsBox = styled('div', `
position: relative;
flex: auto;
--icon-color: ${theme.lightText};
color: ${theme.lightText};
font-weight: normal;
display: flex;
align-items: center;
&:first-child {
margin-right: 16px;
}
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssNumLabel = styled('div', `
position: absolute;
padding-left: 8px;
pointer-events: none;
`);
const cssNumInput = styled('input', `
padding: 4px 32px 4px 40px;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
background-color: ${theme.inputBg};
color: ${theme.inputFg};
width: 100%;
text-align: right;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
`);
const cssSpinner = styled('div', `
position: absolute;
right: 8px;
width: 16px;
height: 100%;
display: flex;
flex-direction: column;
`);
const cssSpinnerBtn = styled('div', `
--icon-color: ${theme.controlSecondaryFg};
flex: 1 1 0px;
min-height: 0px;
position: relative;
cursor: pointer;
overflow: hidden;
&:hover {
--icon-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSpinnerTop = styled(icon, `
position: absolute;
top: 0px;
`);
const cssSpinnerBottom = styled(icon, `
position: absolute;
bottom: 0px;
`);
const cssModeSelect = styled(makeButtonSelect, `
flex: 4 4 0px;
background-color: ${theme.inputBg};

@ -1,3 +1,8 @@
import {
FormFieldRulesConfig,
FormOptionsSortConfig,
FormSelectConfig
} from 'app/client/components/Forms/FormConfig';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel';
@ -72,7 +77,9 @@ export class Reference extends NTextBox {
public buildFormConfigDom() {
return [
this.buildTransformConfigDom(),
super.buildFormConfigDom(),
dom.create(FormSelectConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}

@ -1,3 +1,8 @@
import {
FormFieldRulesConfig,
FormOptionsAlignmentConfig,
FormOptionsSortConfig,
} from 'app/client/components/Forms/FormConfig';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {urlState} from 'app/client/models/gristUrlState';
import {testId, theme} from 'app/client/ui2018/cssVars';
@ -103,6 +108,15 @@ export class ReferenceList extends Reference {
}),
);
}
public buildFormConfigDom() {
return [
this.buildTransformConfigDom(),
dom.create(FormOptionsAlignmentConfig, this.field),
dom.create(FormOptionsSortConfig, this.field),
dom.create(FormFieldRulesConfig, this.field),
];
}
}
const cssRefIcon = styled(icon, `

@ -1,19 +1,44 @@
import * as commands from 'app/client/components/commands';
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { fromKoSave } from 'app/client/lib/fromKoSave';
import { makeT } from 'app/client/lib/localization';
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { KoSaveableObservable } from 'app/client/models/modelUtil';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { fieldWithDefault, KoSaveableObservable } from 'app/client/models/modelUtil';
import { FormToggleFormat } from 'app/client/ui/FormAPI';
import { cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
import { theme } from 'app/client/ui2018/cssVars';
import { dom, DomContents } from 'grainjs';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { dom, DomContents, makeTestId } from 'grainjs';
const t = makeT('Toggle');
const testId = makeTestId('test-toggle-');
/**
* ToggleBase - The base class for toggle widgets, such as a checkbox or a switch.
*/
abstract class ToggleBase extends NewAbstractWidget {
public buildFormConfigDom(): DomContents {
const format = fieldWithDefault<FormToggleFormat>(
this.field.widgetOptionsJson.prop('formToggleFormat'),
'switch'
);
return [
dom.create(FieldRulesConfig, this.field),
cssLabel(t('Field Format')),
cssRow(
buttonSelect(
fromKoSave(format),
[
{value: 'switch', label: t('Switch')},
{value: 'checkbox', label: t('Checkbox')},
],
testId('form-field-format'),
),
),
dom.create(FormFieldRulesConfig, this.field),
];
}

@ -154,6 +154,7 @@ export const typeDefs: any = {
widgets: {
TextBox: {
cons: 'TextBox',
formCons: 'Switch',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {

@ -65,6 +65,12 @@ export function getWidgetConstructor(widget: string, type: string): WidgetConstr
return nameToWidget[config.cons as keyof typeof nameToWidget] as any;
}
/** return a good class to instantiate for viewing a form widget/type combination */
export function getFormWidgetConstructor(widget: string, type: string): WidgetConstructor {
const {config} = getWidgetConfiguration(widget, type as GristType);
return nameToWidget[(config.formCons || config.cons) as keyof typeof nameToWidget] as any;
}
/** return a good class to instantiate for editing a widget/type combination */
export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor {
const {config} = getWidgetConfiguration(widget, type as GristType);

@ -175,6 +175,21 @@ export async function firstDefined<T>(...list: Array<() => Promise<T>>): Promise
return undefined;
}
/**
* Returns the number repesentation of `value`, or `defaultVal` if it cannot
* be represented as a valid number.
*/
export function numberOrDefault<T>(value: unknown, defaultVal: T): number | T {
if (typeof value === 'number') {
return !Number.isNaN(value) ? value : defaultVal;
} else if (typeof value === 'string') {
const maybeNumber = Number.parseFloat(value);
return !Number.isNaN(maybeNumber) ? maybeNumber : defaultVal;
} else {
return defaultVal;
}
}
/**
* Parses json and returns the result, or returns defaultVal if parsing fails.
*/

@ -12,7 +12,13 @@ import {
UserAction
} from 'app/common/DocActions';
import {DocData} from 'app/common/DocData';
import {extractTypeFromColType, isBlankValue, isFullReferencingType, isRaisedException} from "app/common/gristTypes";
import {
extractTypeFromColType,
getReferencedTableId,
isBlankValue,
isFullReferencingType,
isRaisedException,
} from "app/common/gristTypes";
import {INITIAL_FIELDS_COUNT} from "app/common/Forms";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
@ -260,9 +266,15 @@ export class DocWorkerApi {
}
function asRecords(
columnData: TableColValues, opts?: { optTableId?: string; includeHidden?: boolean }): TableRecordValue[] {
columnData: TableColValues,
opts?: {
optTableId?: string;
includeHidden?: boolean;
includeId?: boolean;
}
): TableRecordValue[] {
const fieldNames = Object.keys(columnData).filter((k) => {
if (k === "id") {
if (!opts?.includeId && k === "id") {
return false;
}
if (
@ -1451,9 +1463,8 @@ export class DocWorkerApi {
}
// Cache the table reads based on tableId. We are caching only the promise, not the result.
const table = _.memoize(
(tableId: string) => readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r))
);
const table = _.memoize((tableId: string) =>
readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r, {includeId: true})));
const getTableValues = async (tableId: string, colId: string) => {
const records = await table(tableId);
@ -1463,19 +1474,17 @@ export class DocWorkerApi {
const Tables = activeDoc.docData.getMetaTable('_grist_Tables');
const getRefTableValues = async (col: MetaRowRecord<'_grist_Tables_column'>) => {
const refId = col.visibleCol;
if (!refId) { return [] as any; }
const refCol = Tables_column.getRecord(refId);
if (!refCol) { return []; }
const refTableId = getReferencedTableId(col.type);
let refColId: string;
if (col.visibleCol) {
const refCol = Tables_column.getRecord(col.visibleCol);
if (!refCol) { return []; }
const refTable = Tables.getRecord(refCol.parentId);
if (!refTable) { return []; }
const refTableId = refTable.tableId as string;
const refColId = refCol.colId as string;
if (!refTableId || !refColId) { return () => []; }
if (typeof refTableId !== 'string' || typeof refColId !== 'string') { return []; }
refColId = refCol.colId as string;
} else {
refColId = 'id';
}
if (!refTableId || typeof refTableId !== 'string' || !refColId) { return []; }
const values = await getTableValues(refTableId, refColId);
return values.filter(([_id, value]) => !isBlankValue(value));

@ -5,7 +5,7 @@ import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('FormView', function() {
this.timeout('90s');
this.timeout('2m');
gu.bigScreen();
let api: UserAPI;
@ -80,9 +80,9 @@ describe('FormView', function() {
async function waitForConfirm() {
await gu.waitForServer();
await gu.waitToPass(async () => {
assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed());
assert.isTrue(await driver.findWait('.test-form-success-page', 2000).isDisplayed());
assert.equal(
await driver.find('.test-form-success-text').getText(),
await driver.find('.test-form-success-page-text').getText(),
'Thank you! Your response has been recorded.'
);
});
@ -96,6 +96,12 @@ describe('FormView', function() {
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), values);
}
async function assertSubmitOnEnterIsDisabled() {
await gu.sendKeys(Key.ENTER);
await gu.waitForServer();
assert.isFalse(await driver.find('.test-form-success-page').isPresent());
}
describe('on personal site', async function() {
before(async function() {
const session = await gu.session().login();
@ -157,7 +163,7 @@ describe('FormView', function() {
await removeForm();
});
it('can submit a form with Text field', async function() {
it('can submit a form with single-line Text field', async function() {
const formUrl = await createFormWith('Text');
// We are in a new window.
await gu.onNewTab(async () => {
@ -170,6 +176,7 @@ describe('FormView', function() {
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('Hello World');
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -178,7 +185,32 @@ describe('FormView', function() {
await removeForm();
});
it('can submit a form with Numeric field', async function() {
it('can submit a form with multi-line Text field', async function() {
const formUrl = await createFormWith('Text');
await gu.openColumnPanel();
await gu.waitForSidePanel();
await driver.findContent('.test-tb-form-field-format .test-select-button', /Multi line/).click();
await gu.waitForServer();
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('textarea[name="D"]', 2000).click();
await gu.sendKeys('Hello');
assert.equal(await driver.find('textarea[name="D"]').value(), 'Hello');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('textarea[name="D"]').value(), '');
await driver.find('textarea[name="D"]').click();
await gu.sendKeys('Hello,', Key.ENTER, 'World');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectSingle('Hello,\nWorld');
await removeForm();
});
it('can submit a form with text Numeric field', async function() {
const formUrl = await createFormWith('Numeric');
// We are in a new window.
await gu.onNewTab(async () => {
@ -191,6 +223,38 @@ describe('FormView', function() {
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984');
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectSingle(1984);
await removeForm();
});
it('can submit a form with spinner Numeric field', async function() {
const formUrl = await createFormWith('Numeric');
await driver.findContent('.test-numeric-form-field-format .test-select-button', /Spinner/).click();
await gu.waitForServer();
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1983');
assert.equal(await driver.find('input[name="D"]').value(), '1983');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984', Key.ARROW_UP);
assert.equal(await driver.find('input[name="D"]').value(), '1985');
await gu.sendKeys(Key.ARROW_DOWN);
assert.equal(await driver.find('input[name="D"]').value(), '1984');
await driver.find('.test-numeric-spinner-increment').click();
assert.equal(await driver.find('input[name="D"]').value(), '1985');
await driver.find('.test-numeric-spinner-decrement').click();
assert.equal(await driver.find('input[name="D"]').value(), '1984');
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -212,6 +276,7 @@ describe('FormView', function() {
assert.equal(await driver.find('input[name="D"]').getAttribute('value'), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('01012000');
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -220,17 +285,14 @@ describe('FormView', function() {
await removeForm();
});
it('can submit a form with Choice field', async function() {
it('can submit a form with select Choice field', async function() {
const formUrl = await createFormWith('Choice');
// Add some options.
await gu.openColumnPanel();
await gu.choicesEditor.edit();
await gu.choicesEditor.add('Foo');
await gu.choicesEditor.add('Bar');
await gu.choicesEditor.add('Baz');
await gu.choicesEditor.save();
await gu.toggleSidePanel('right', 'close');
// We need to press view, as form is not saved yet.
await gu.scrollActiveViewTop();
@ -256,6 +318,12 @@ describe('FormView', function() {
assert.equal(await driver.find('select[name="D"]').value(), '');
await driver.find('.test-form-search-select').click();
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
// Check keyboard shortcuts work.
assert.equal(await driver.find('.test-form-search-select').getText(), 'Bar');
await gu.sendKeys(Key.BACK_SPACE);
assert.equal(await driver.find('.test-form-search-select').getText(), 'Select...');
await gu.sendKeys(Key.ENTER);
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -263,7 +331,41 @@ describe('FormView', function() {
await removeForm();
});
it('can submit a form with Integer field', async function() {
it('can submit a form with radio Choice field', async function() {
const formUrl = await createFormWith('Choice');
await driver.findContent('.test-form-field-format .test-select-button', /Radio/).click();
await gu.waitForServer();
await gu.choicesEditor.edit();
await gu.choicesEditor.add('Foo');
await gu.choicesEditor.add('Bar');
await gu.choicesEditor.add('Baz');
await gu.choicesEditor.save();
await gu.scrollActiveViewTop();
await gu.waitToPass(async () => {
assert.isTrue(await driver.find('.test-forms-view').isDisplayed());
});
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000);
assert.deepEqual(
await driver.findAll('label:has(input[name="D"])', e => e.getText()), ['Foo', 'Bar', 'Baz']
);
await driver.find('input[name="D"][value="Baz"]').click();
assert.equal(await driver.find('input[name="D"][value="Baz"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"][value="Baz"]').getAttribute('checked'), null);
await driver.find('input[name="D"][value="Bar"]').click();
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expectSingle('Bar');
await removeForm();
});
it('can submit a form with text Integer field', async function() {
const formUrl = await createFormWith('Integer', true);
// We are in a new window.
await gu.onNewTab(async () => {
@ -276,6 +378,7 @@ describe('FormView', function() {
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984');
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -284,7 +387,38 @@ describe('FormView', function() {
await removeForm();
});
it('can submit a form with Toggle field', async function() {
it('can submit a form with spinner Integer field', async function() {
const formUrl = await createFormWith('Integer', true);
await driver.findContent('.test-numeric-form-field-format .test-select-button', /Spinner/).click();
await gu.waitForServer();
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).click();
await gu.sendKeys('1983');
assert.equal(await driver.find('input[name="D"]').value(), '1983');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').value(), '');
await driver.find('input[name="D"]').click();
await gu.sendKeys('1984', Key.ARROW_UP);
assert.equal(await driver.find('input[name="D"]').value(), '1985');
await gu.sendKeys(Key.ARROW_DOWN);
assert.equal(await driver.find('input[name="D"]').value(), '1984');
await driver.find('.test-numeric-spinner-increment').click();
assert.equal(await driver.find('input[name="D"]').value(), '1985');
await driver.find('.test-numeric-spinner-decrement').click();
assert.equal(await driver.find('input[name="D"]').value(), '1984');
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectSingle(1984);
await removeForm();
});
it('can submit a form with switch Toggle field', async function() {
const formUrl = await createFormWith('Toggle', true);
// We are in a new window.
await gu.onNewTab(async () => {
@ -295,6 +429,39 @@ describe('FormView', function() {
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null);
await driver.find('input[name="D"]').findClosest("label").click();
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expectSingle(true);
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[type="submit"]', 2000).click();
await waitForConfirm();
});
await expectInD([true, false]);
// Remove the additional record added just now.
await gu.sendActions([
['RemoveRecord', 'Table1', 2],
]);
await removeForm();
});
it('can submit a form with checkbox Toggle field', async function() {
const formUrl = await createFormWith('Toggle', true);
await driver.findContent('.test-toggle-form-field-format .test-select-button', /Checkbox/).click();
await gu.waitForServer();
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000).findClosest("label").click();
assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"]').getAttribute('checked'), null);
await driver.find('input[name="D"]').findClosest("label").click();
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -334,6 +501,7 @@ describe('FormView', function() {
assert.equal(await driver.find('input[name="D[]"][value="Bar"]').getAttribute('checked'), null);
await driver.find('input[name="D[]"][value="Foo"]').click();
await driver.find('input[name="D[]"][value="Baz"]').click();
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -342,7 +510,7 @@ describe('FormView', function() {
await removeForm();
});
it('can submit a form with Ref field', async function() {
it('can submit a form with select Ref field', async function() {
const formUrl = await createFormWith('Reference', true);
// Add some options.
await gu.openColumnPanel();
@ -353,22 +521,21 @@ describe('FormView', function() {
['AddRecord', 'Table1', null, {A: 'Bar'}], // id 2
['AddRecord', 'Table1', null, {A: 'Baz'}], // id 3
]);
await gu.toggleSidePanel('right', 'close');
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('select[name="D"]', 2000);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.getText()),
['Select...', ...['Bar', 'Baz', 'Foo']]
['Select...', 'Foo', 'Bar', 'Baz']
);
assert.deepEqual(
await driver.findAll('select[name="D"] option', e => e.value()),
['', ...['2', '3', '1']]
['', '1', '2', '3']
);
await driver.find('.test-form-search-select').click();
assert.deepEqual(
await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Bar', 'Baz', 'Foo']
await driver.findAll('.test-sd-searchable-list-item', e => e.getText()), ['Select...', 'Foo', 'Bar', 'Baz']
);
await gu.sendKeys('Baz', Key.ENTER);
assert.equal(await driver.find('select[name="D"]').value(), '3');
@ -377,6 +544,51 @@ describe('FormView', function() {
assert.equal(await driver.find('select[name="D"]').value(), '');
await driver.find('.test-form-search-select').click();
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
// Check keyboard shortcuts work.
assert.equal(await driver.find('.test-form-search-select').getText(), 'Bar');
await gu.sendKeys(Key.BACK_SPACE);
assert.equal(await driver.find('.test-form-search-select').getText(), 'Select...');
await gu.sendKeys(Key.ENTER);
await driver.findContent('.test-sd-searchable-list-item', 'Bar').click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expectInD([0, 0, 0, 2]);
// Remove 3 records.
await gu.sendActions([
['BulkRemoveRecord', 'Table1', [1, 2, 3, 4]],
]);
await removeForm();
});
it('can submit a form with radio Ref field', async function() {
const formUrl = await createFormWith('Reference', true);
await driver.findContent('.test-form-field-format .test-select-button', /Radio/).click();
await gu.waitForServer();
await gu.setRefShowColumn('A');
await gu.sendActions([
['AddRecord', 'Table1', null, {A: 'Foo'}],
['AddRecord', 'Table1', null, {A: 'Bar'}],
['AddRecord', 'Table1', null, {A: 'Baz'}],
]);
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 2000);
assert.deepEqual(
await driver.findAll('label:has(input[name="D"])', e => e.getText()), ['Foo', 'Bar', 'Baz']
);
assert.equal(await driver.find('label:has(input[name="D"][value="3"])').getText(), 'Baz');
await driver.find('input[name="D"][value="3"]').click();
assert.equal(await driver.find('input[name="D"][value="3"]').getAttribute('checked'), 'true');
await driver.find('.test-form-reset').click();
await driver.find('.test-modal-confirm').click();
assert.equal(await driver.find('input[name="D"][value="3"]').getAttribute('checked'), null);
assert.equal(await driver.find('label:has(input[name="D"][value="2"])').getText(), 'Bar');
await driver.find('input[name="D"][value="2"]').click();
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -393,8 +605,6 @@ describe('FormView', function() {
it('can submit a form with RefList field', async function() {
const formUrl = await createFormWith('Reference List', true);
// Add some options.
await gu.openColumnPanel();
await gu.setRefShowColumn('A');
// Add 3 records to this table (it is now empty).
await gu.sendActions([
@ -416,6 +626,7 @@ describe('FormView', function() {
assert.equal(await driver.find('input[name="D[]"][value="1"]').getAttribute('checked'), null);
await driver.find('input[name="D[]"][value="1"]').click();
await driver.find('input[name="D[]"][value="2"]').click();
await assertSubmitOnEnterIsDisabled();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
@ -542,9 +753,9 @@ describe('FormView', function() {
await gu.waitForServer();
await gu.onNewTab(async () => {
await driver.get(formUrl);
assert.isTrue(await driver.findWait('.test-form-container', 2000).isDisplayed());
assert.isTrue(await driver.findWait('.test-form-error-page', 2000).isDisplayed());
assert.equal(
await driver.find('.test-form-error-text').getText(),
await driver.find('.test-form-error-page-text').getText(),
'Oops! This form is no longer published.'
);
});
@ -739,8 +950,8 @@ describe('FormView', function() {
// Now B is selected.
assert.equal(await selectedLabel(), 'B');
// Click on the edit button.
await driver.find('.test-forms-submit').click();
// Click the blank space above the submit button.
await driver.find('.test-forms-error').click();
// Now nothing is selected.
assert.isFalse(await isSelected(), 'Something is selected');
@ -825,7 +1036,6 @@ describe('FormView', function() {
assert.deepEqual(await hiddenColumns(), []);
// Now hide it using Delete key.
await driver.find('.test-forms-submit').click();
await question('Choice').click();
await gu.sendKeys(Key.DELETE);
await gu.waitForServer();
@ -833,8 +1043,20 @@ describe('FormView', function() {
// It should be hidden again.
assert.deepEqual(await hiddenColumns(), ['Choice']);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
});
it('changing field types works', async function() {
await gu.openColumnPanel();
assert.equal(await questionType('A'), 'Any');
await question('A').click();
await gu.setType('Text');
assert.equal(await questionType('A'), 'Text');
await gu.sendActions([['AddRecord', 'Form', null, {A: 'Foo'}]]);
await question('A').click();
await gu.setType('Numeric', {apply: true});
assert.equal(await questionType('A'), 'Numeric');
await gu.sendActions([['RemoveRecord', 'Form', 1]]);
await gu.undo(2);
await gu.toggleSidePanel('right', 'close');
});

Loading…
Cancel
Save