mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) New Grist Forms styling and field options
Summary: - New styling for forms. - New field options for various field types (spinner, checkbox, radio buttons, alignment, sort). - Improved alignment of form fields in columns. - Support for additional select input keyboard shortcuts (Enter and Backspace). - Prevent submitting form on Enter if an input has focus. - Fix for changing form field type causing the field to disappear. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4223
This commit is contained in:
@@ -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);
|
||||
|
||||
const choices: [number|string, CellValue][] = this.field.refValues ?? [];
|
||||
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.
|
||||
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() {
|
||||
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])));
|
||||
// Support for 1000 choices. TODO: make limit dynamic.
|
||||
choices.splice(1000);
|
||||
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.model.field).colId)),
|
||||
{disabled: true},
|
||||
dom.prop('name', u => u(u(this.field).colId)),
|
||||
{type: 'text', tabIndex: "-1"},
|
||||
);
|
||||
}
|
||||
|
||||
private _renderMultiLineInput() {
|
||||
return css.cssTextArea(
|
||||
dom.prop('name', u => u(u(this.field).colId)),
|
||||
dom.prop('rows', this._rowCount),
|
||||
{tabIndex: "-1"},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceModel extends Question {
|
||||
protected choices: Computed<string[]> = Computed.create(this, use => {
|
||||
// Read choices from field.
|
||||
const choices = use(use(this.model.field).widgetOptionsJson.prop('choices'));
|
||||
|
||||
// Make sure it is an array of strings.
|
||||
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
|
||||
return [];
|
||||
} else {
|
||||
return choices;
|
||||
}
|
||||
class NumericModel extends Question {
|
||||
private _format = Computed.create<FormNumberFormat>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formNumberFormat')) ?? 'text';
|
||||
});
|
||||
|
||||
public renderInput(): HTMLElement {
|
||||
const field = this.model.field;
|
||||
public renderInput() {
|
||||
return dom.domComputed(this._format, (format) => {
|
||||
switch (format) {
|
||||
case 'text': {
|
||||
return this._renderTextInput();
|
||||
}
|
||||
case 'spinner': {
|
||||
return this._renderSpinnerInput();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _renderTextInput() {
|
||||
return css.cssInput(
|
||||
dom.prop('name', u => u(u(this.field).colId)),
|
||||
{type: 'text', tabIndex: "-1"},
|
||||
);
|
||||
}
|
||||
|
||||
private _renderSpinnerInput() {
|
||||
return css.cssSpinner(observable(''), {});
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceModel extends Question {
|
||||
protected choices: Computed<string[]>;
|
||||
|
||||
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
|
||||
});
|
||||
|
||||
private _format = Computed.create<FormSelectFormat>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select';
|
||||
});
|
||||
|
||||
private _sortOrder = Computed.create<FormOptionsSortOrder>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default';
|
||||
});
|
||||
|
||||
constructor(model: FieldModel) {
|
||||
super(model);
|
||||
this.choices = Computed.create(this, use => {
|
||||
// Read choices from field.
|
||||
const field = use(this.field);
|
||||
const choices = use(field.widgetOptionsJson.prop('choices'))?.slice() ?? [];
|
||||
|
||||
// Make sure it is an array of strings.
|
||||
if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) {
|
||||
return [];
|
||||
} else {
|
||||
const sort = use(this._sortOrder);
|
||||
if (sort !== 'default') {
|
||||
choices.sort((a, b) => a.localeCompare(b));
|
||||
if (sort === 'descending') {
|
||||
choices.reverse();
|
||||
}
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
dom.domComputed(this._format, (format) => {
|
||||
if (format === 'select') {
|
||||
return this._renderSelectInput();
|
||||
} else {
|
||||
return this._renderRadioInput();
|
||||
}
|
||||
}),
|
||||
dom.maybe(use => use(this.choices).length === 0, () => [
|
||||
css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private _renderSelectInput() {
|
||||
return css.cssSelect(
|
||||
{tabIndex: "-1"},
|
||||
ignoreClick,
|
||||
dom.prop('name', use => use(use(field).colId)),
|
||||
dom('option', SELECT_PLACEHOLDER, {value: ''}),
|
||||
dom.forEach(this.choices, (choice) => dom('option', choice, {value: choice})),
|
||||
dom.prop('name', use => use(use(this.field).colId)),
|
||||
dom('option',
|
||||
SELECT_PLACEHOLDER,
|
||||
{value: ''},
|
||||
),
|
||||
dom.forEach(this.choices, (choice) => dom('option',
|
||||
choice,
|
||||
{value: choice},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
private _renderRadioInput() {
|
||||
return css.cssRadioList(
|
||||
css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
||||
dom.prop('name', use => use(use(this.field).colId)),
|
||||
dom.forEach(this.choices, (choice) => css.cssRadioLabel(
|
||||
cssRadioInput({type: 'radio'}),
|
||||
choice,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -305,21 +435,28 @@ class ChoiceListModel extends ChoiceModel {
|
||||
});
|
||||
|
||||
public renderInput() {
|
||||
const field = this.model.field;
|
||||
return dom('div',
|
||||
const field = this.field;
|
||||
return css.cssCheckboxList(
|
||||
css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
||||
dom.prop('name', use => use(use(field).colId)),
|
||||
dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
|
||||
squareCheckbox(observable(false)),
|
||||
choice
|
||||
css.cssCheckboxLabel.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
||||
cssCheckboxSquare({type: 'checkbox'}),
|
||||
choice,
|
||||
)),
|
||||
dom.maybe(use => use(this._choices).length === 0, () => [
|
||||
dom('div', 'No choices defined'),
|
||||
css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BoolModel extends Question {
|
||||
private _format = Computed.create<FormToggleFormat>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formToggleFormat')) ?? 'switch';
|
||||
});
|
||||
|
||||
public override buildDom(props: {
|
||||
edit: Observable<boolean>,
|
||||
overlay: Observable<boolean>,
|
||||
@@ -329,22 +466,37 @@ class BoolModel extends Question {
|
||||
return css.cssQuestion(
|
||||
testId('question'),
|
||||
testType(this.model.colType),
|
||||
cssToggle(
|
||||
css.cssToggle(
|
||||
this.renderInput(),
|
||||
this.renderLabel(props, css.cssLabelInline.cls('')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public override renderInput() {
|
||||
const value = Observable.create(this, true);
|
||||
return dom('div.widget_switch',
|
||||
return dom.domComputed(this._format, (format) => {
|
||||
if (format === 'switch') {
|
||||
return this._renderSwitchInput();
|
||||
} else {
|
||||
return this._renderCheckboxInput();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _renderSwitchInput() {
|
||||
return css.cssWidgetSwitch(
|
||||
dom.style('--grist-actual-cell-color', colors.lightGreen.toString()),
|
||||
dom.cls('switch_on', value),
|
||||
dom.cls('switch_transition', true),
|
||||
dom.cls('switch_transition'),
|
||||
dom('div.switch_slider'),
|
||||
dom('div.switch_circle'),
|
||||
);
|
||||
}
|
||||
|
||||
private _renderCheckboxInput() {
|
||||
return cssLabel(
|
||||
cssCheckboxSquare({type: 'checkbox'}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DateModel extends Question {
|
||||
@@ -352,8 +504,8 @@ class DateModel extends Question {
|
||||
return dom('div',
|
||||
css.cssInput(
|
||||
dom.prop('name', this.model.colId),
|
||||
{type: 'date', style: 'margin-right: 5px; width: 100%;'
|
||||
}),
|
||||
{type: 'date', style: 'margin-right: 5px;'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -363,7 +515,7 @@ class DateTimeModel extends Question {
|
||||
return dom('div',
|
||||
css.cssInput(
|
||||
dom.prop('name', this.model.colId),
|
||||
{type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'}
|
||||
{type: 'datetime-local', style: 'margin-right: 5px;'},
|
||||
),
|
||||
dom.style('width', '100%'),
|
||||
);
|
||||
@@ -371,19 +523,38 @@ class DateTimeModel extends Question {
|
||||
}
|
||||
|
||||
class RefListModel extends Question {
|
||||
protected options = this._getOptions();
|
||||
protected options: Computed<{label: string, value: string}[]>;
|
||||
|
||||
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
|
||||
});
|
||||
|
||||
private _sortOrder = Computed.create<FormOptionsSortOrder>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formOptionsSortOrder')) ?? 'default';
|
||||
});
|
||||
|
||||
constructor(model: FieldModel) {
|
||||
super(model);
|
||||
this.options = this._getOptions();
|
||||
}
|
||||
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
return css.cssCheckboxList(
|
||||
css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
||||
dom.prop('name', this.model.colId),
|
||||
dom.forEach(this.options, (option) => css.cssCheckboxLabel(
|
||||
squareCheckbox(observable(false)),
|
||||
option.label,
|
||||
)),
|
||||
dom.maybe(use => use(this.options).length === 0, () => [
|
||||
dom('div', 'No values in show column of referenced table'),
|
||||
css.cssWarningMessage(
|
||||
css.cssWarningIcon('Warning'),
|
||||
t('No values in show column of referenced table'),
|
||||
),
|
||||
]),
|
||||
) as HTMLElement;
|
||||
);
|
||||
}
|
||||
|
||||
private _getOptions() {
|
||||
@@ -394,39 +565,83 @@ class RefListModel extends Question {
|
||||
|
||||
const colId = Computed.create(this, use => {
|
||||
const dispColumnIdObs = use(use(this.model.column).visibleColModel);
|
||||
return use(dispColumnIdObs.colId);
|
||||
return use(dispColumnIdObs.colId) || 'id';
|
||||
});
|
||||
|
||||
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
|
||||
|
||||
return Computed.create(this, use => {
|
||||
return use(observer)
|
||||
const sort = use(this._sortOrder);
|
||||
const values = use(observer)
|
||||
.filter(([_id, value]) => !isBlankValue(value))
|
||||
.map(([id, value]) => ({label: String(value), value: String(id)}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.slice(0, 30); // TODO: make limit dynamic.
|
||||
.map(([id, value]) => ({label: String(value), value: String(id)}));
|
||||
if (sort !== 'default') {
|
||||
values.sort((a, b) => a.label.localeCompare(b.label));
|
||||
if (sort === 'descending') {
|
||||
values.reverse();
|
||||
}
|
||||
}
|
||||
return values.slice(0, 30);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RefModel extends RefListModel {
|
||||
private _format = Computed.create<FormSelectFormat>(this, (use) => {
|
||||
const field = use(this.field);
|
||||
return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select';
|
||||
});
|
||||
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
dom.domComputed(this._format, (format) => {
|
||||
if (format === 'select') {
|
||||
return this._renderSelectInput();
|
||||
} else {
|
||||
return this._renderRadioInput();
|
||||
}
|
||||
}),
|
||||
dom.maybe(use => use(this.options).length === 0, () => [
|
||||
css.cssWarningMessage(
|
||||
css.cssWarningIcon('Warning'),
|
||||
t('No values in show column of referenced table'),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private _renderSelectInput() {
|
||||
return css.cssSelect(
|
||||
{tabIndex: "-1"},
|
||||
ignoreClick,
|
||||
dom.prop('name', this.model.colId),
|
||||
dom('option', SELECT_PLACEHOLDER, {value: ''}),
|
||||
dom.forEach(this.options, ({label, value}) => dom('option', label, {value})),
|
||||
dom('option',
|
||||
SELECT_PLACEHOLDER,
|
||||
{value: ''},
|
||||
),
|
||||
dom.forEach(this.options, ({label, value}) => dom('option',
|
||||
label,
|
||||
{value},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
private _renderRadioInput() {
|
||||
return css.cssRadioList(
|
||||
css.cssRadioList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
||||
dom.prop('name', use => use(use(this.field).colId)),
|
||||
dom.forEach(this.options, ({label, value}) => css.cssRadioLabel(
|
||||
cssRadioInput({type: 'radio'}),
|
||||
label,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: decide which one we need and implement rest.
|
||||
const AnyModel = TextModel;
|
||||
const NumericModel = TextModel;
|
||||
const IntModel = TextModel;
|
||||
const AttachmentsModel = TextModel;
|
||||
|
||||
// Attachments are not currently supported.
|
||||
const AttachmentsModel = TextModel;
|
||||
|
||||
function fieldConstructor(type: string): Constructor<Question> {
|
||||
switch (type) {
|
||||
@@ -436,7 +651,7 @@ function fieldConstructor(type: string): Constructor<Question> {
|
||||
case 'ChoiceList': return ChoiceListModel;
|
||||
case 'Date': return DateModel;
|
||||
case 'DateTime': return DateTimeModel;
|
||||
case 'Int': return IntModel;
|
||||
case 'Int': return NumericModel;
|
||||
case 'Numeric': return NumericModel;
|
||||
case 'Ref': return RefModel;
|
||||
case 'RefList': return RefListModel;
|
||||
@@ -451,12 +666,3 @@ function fieldConstructor(type: string): Constructor<Question> {
|
||||
function testType(value: BindableValue<string>) {
|
||||
return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
|
||||
}
|
||||
|
||||
const cssToggle = styled('div', `
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px;
|
||||
padding: 4px 0px;
|
||||
--grist-actual-cell-color: ${colors.lightGreen};
|
||||
`);
|
||||
|
||||
@@ -1,25 +1,119 @@
|
||||
import {fromKoSave} from 'app/client/lib/fromKoSave';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ViewFieldRec} from 'app/client/models/DocModel';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
|
||||
import {fieldWithDefault} from 'app/client/models/modelUtil';
|
||||
import {FormOptionsAlignment, FormOptionsSortOrder, FormSelectFormat} from 'app/client/ui/FormAPI';
|
||||
import {
|
||||
cssLabel,
|
||||
cssRow,
|
||||
cssSeparator,
|
||||
} from 'app/client/ui/RightPanelStyles';
|
||||
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {Disposable} from 'grainjs';
|
||||
import {select} from 'app/client/ui2018/menus';
|
||||
import {Disposable, dom, makeTestId} from 'grainjs';
|
||||
|
||||
const t = makeT('FormConfig');
|
||||
|
||||
export class FieldRulesConfig extends Disposable {
|
||||
const testId = makeTestId('test-form-');
|
||||
|
||||
export class FormSelectConfig extends Disposable {
|
||||
constructor(private _field: ViewFieldRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const requiredField: KoSaveableObservable<boolean> = this._field.widgetOptionsJson.prop('formRequired');
|
||||
const format = fieldWithDefault<FormSelectFormat>(
|
||||
this._field.widgetOptionsJson.prop('formSelectFormat'),
|
||||
'select'
|
||||
);
|
||||
|
||||
return [
|
||||
cssLabel(t('Field Format')),
|
||||
cssRow(
|
||||
buttonSelect(
|
||||
fromKoSave(format),
|
||||
[
|
||||
{value: 'select', label: t('Select')},
|
||||
{value: 'radio', label: t('Radio')},
|
||||
],
|
||||
testId('field-format'),
|
||||
),
|
||||
),
|
||||
dom.maybe(use => use(format) === 'radio', () => dom.create(FormOptionsAlignmentConfig, this._field)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class FormOptionsAlignmentConfig extends Disposable {
|
||||
constructor(private _field: ViewFieldRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const alignment = fieldWithDefault<FormOptionsAlignment>(
|
||||
this._field.widgetOptionsJson.prop('formOptionsAlignment'),
|
||||
'vertical'
|
||||
);
|
||||
|
||||
return [
|
||||
cssLabel(t('Options Alignment')),
|
||||
cssRow(
|
||||
select(
|
||||
fromKoSave(alignment),
|
||||
[
|
||||
{value: 'vertical', label: t('Vertical')},
|
||||
{value: 'horizontal', label: t('Horizontal')},
|
||||
],
|
||||
{defaultLabel: t('Vertical')}
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class FormOptionsSortConfig extends Disposable {
|
||||
constructor(private _field: ViewFieldRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const optionsSortOrder = fieldWithDefault<FormOptionsSortOrder>(
|
||||
this._field.widgetOptionsJson.prop('formOptionsSortOrder'),
|
||||
'default'
|
||||
);
|
||||
|
||||
return [
|
||||
cssLabel(t('Options Sort Order')),
|
||||
cssRow(
|
||||
select(
|
||||
fromKoSave(optionsSortOrder),
|
||||
[
|
||||
{value: 'default', label: t('Default')},
|
||||
{value: 'ascending', label: t('Ascending')},
|
||||
{value: 'descending', label: t('Descending')},
|
||||
],
|
||||
{defaultLabel: t('Default')}
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class FormFieldRulesConfig extends Disposable {
|
||||
constructor(private _field: ViewFieldRec) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const requiredField = fieldWithDefault<boolean>(
|
||||
this._field.widgetOptionsJson.prop('formRequired'),
|
||||
false
|
||||
);
|
||||
|
||||
return [
|
||||
cssSeparator(),
|
||||
cssLabel(t('Field rules')),
|
||||
cssLabel(t('Field Rules')),
|
||||
cssRow(labeledSquareCheckbox(
|
||||
fromKoSave(requiredField),
|
||||
t('Required field'),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
import DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {jsonObservable, SaveableObjObservable} from 'app/client/models/modelUtil';
|
||||
import {ShareRec} from 'app/client/models/entities/ShareRec';
|
||||
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
@@ -55,7 +56,8 @@ export class FormView extends Disposable {
|
||||
protected bundle: (clb: () => Promise<void>) => Promise<void>;
|
||||
|
||||
private _formFields: Computed<ViewFieldRec[]>;
|
||||
private _autoLayout: Computed<FormLayoutNode>;
|
||||
private _layoutSpec: SaveableObjObservable<FormLayoutNode>;
|
||||
private _layout: Computed<FormLayoutNode>;
|
||||
private _root: BoxModel;
|
||||
private _savedLayout: any;
|
||||
private _saving: boolean = false;
|
||||
@@ -67,7 +69,7 @@ export class FormView extends Disposable {
|
||||
private _showPublishedMessage: Observable<boolean>;
|
||||
private _isOwner: boolean;
|
||||
private _openingForm: Observable<boolean>;
|
||||
private _formElement: HTMLElement;
|
||||
private _formEditorBodyElement: HTMLElement;
|
||||
|
||||
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
||||
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
|
||||
@@ -134,28 +136,30 @@ export class FormView extends Disposable {
|
||||
|
||||
this._formFields = Computed.create(this, use => {
|
||||
const fields = use(use(this.viewSection.viewFields).getObservable());
|
||||
return fields.filter(f => use(use(f.column).isFormCol));
|
||||
return fields.filter(f => {
|
||||
const column = use(f.column);
|
||||
return (
|
||||
use(column.pureType) !== 'Attachments' &&
|
||||
!(use(column.isRealFormula) && !use(column.colId).startsWith('gristHelper_Transform'))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this._autoLayout = Computed.create(this, use => {
|
||||
this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => {
|
||||
return layoutSpec ?? buildDefaultFormLayout(this._formFields.get());
|
||||
});
|
||||
|
||||
this._layout = Computed.create(this, use => {
|
||||
const fields = use(this._formFields);
|
||||
const layout = use(this.viewSection.layoutSpecObj);
|
||||
if (!layout || !layout.id) {
|
||||
return this._formTemplate(fields);
|
||||
} else {
|
||||
const patchedLayout = patchLayoutSpec(layout, new Set(fields.map(f => f.id())));
|
||||
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
|
||||
const layoutSpec = use(this._layoutSpec);
|
||||
const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.id())));
|
||||
if (!patchedLayout) { throw new Error('Invalid form layout spec'); }
|
||||
|
||||
return patchedLayout;
|
||||
}
|
||||
return patchedLayout;
|
||||
});
|
||||
|
||||
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async (clb?: () => Promise<void>) => {
|
||||
this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise<void>) => {
|
||||
await this.bundle(async () => {
|
||||
// If the box is autogenerated we need to save it first.
|
||||
if (!this.viewSection.layoutSpecObj.peek()?.id) {
|
||||
await this.save();
|
||||
}
|
||||
if (clb) {
|
||||
await clb();
|
||||
}
|
||||
@@ -163,7 +167,7 @@ export class FormView extends Disposable {
|
||||
});
|
||||
}, this));
|
||||
|
||||
this._autoLayout.addListener((v) => {
|
||||
this._layout.addListener((v) => {
|
||||
if (this._saving) {
|
||||
console.warn('Layout changed while saving');
|
||||
return;
|
||||
@@ -421,9 +425,9 @@ export class FormView extends Disposable {
|
||||
public buildDom() {
|
||||
return style.cssFormView(
|
||||
testId('editor'),
|
||||
style.cssFormEditBody(
|
||||
this._formEditorBodyElement = style.cssFormEditBody(
|
||||
style.cssFormContainer(
|
||||
this._formElement = dom('div', dom.forEach(this._root.children, (child) => {
|
||||
dom('div', dom.forEach(this._root.children, (child) => {
|
||||
if (!child) {
|
||||
return dom('div', 'Empty node');
|
||||
}
|
||||
@@ -433,9 +437,9 @@ export class FormView extends Disposable {
|
||||
}
|
||||
return element;
|
||||
})),
|
||||
this._buildPublisher(),
|
||||
),
|
||||
),
|
||||
this._buildPublisher(),
|
||||
dom.on('click', () => this.selectedBox.set(null)),
|
||||
dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),
|
||||
);
|
||||
@@ -481,7 +485,7 @@ export class FormView extends Disposable {
|
||||
// If nothing has changed, don't bother.
|
||||
if (isEqual(newVersion, this._savedLayout)) { return; }
|
||||
this._savedLayout = newVersion;
|
||||
await this.viewSection.layoutSpecObj.setAndSave(newVersion);
|
||||
await this._layoutSpec.setAndSave(newVersion);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
@@ -861,17 +865,17 @@ export class FormView extends Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
private _getSectionCount() {
|
||||
return [...this._root.filter(box => box.type === 'Section')].length;
|
||||
}
|
||||
|
||||
private _getEstimatedFormHeightPx() {
|
||||
return (
|
||||
// Form content height.
|
||||
this._formElement.scrollHeight +
|
||||
// Plus top/bottom page padding.
|
||||
(2 * 52) +
|
||||
// Plus top/bottom form padding.
|
||||
(2 * 20) +
|
||||
// Plus minimum form error height.
|
||||
38 +
|
||||
// Plus form footer height.
|
||||
// Form height.
|
||||
this._formEditorBodyElement.scrollHeight +
|
||||
// Minus "+" button height in each section.
|
||||
(-32 * this._getSectionCount()) +
|
||||
// Plus form footer height (visible only in the preview and published form).
|
||||
64
|
||||
);
|
||||
}
|
||||
@@ -902,30 +906,6 @@ export class FormView extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a form template based on the fields in the view section.
|
||||
*/
|
||||
private _formTemplate(fields: ViewFieldRec[]): FormLayoutNode {
|
||||
const boxes: FormLayoutNode[] = fields.map(f => {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: 'Field',
|
||||
leaf: f.id(),
|
||||
};
|
||||
});
|
||||
const section = components.Section(...boxes);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: 'Layout',
|
||||
children: [
|
||||
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
|
||||
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
|
||||
section,
|
||||
{id: uuidv4(), type: 'Submit'},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async _resetForm() {
|
||||
this.selectedBox.set(null);
|
||||
await this.gristDoc.docData.bundleActions('Reset form', async () => {
|
||||
@@ -951,11 +931,35 @@ export class FormView extends Disposable {
|
||||
]);
|
||||
|
||||
const fields = this.viewSection.viewFields().all().slice(0, 9);
|
||||
await this.viewSection.layoutSpecObj.setAndSave(this._formTemplate(fields));
|
||||
await this._layoutSpec.setAndSave(buildDefaultFormLayout(fields));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default form layout based on the fields in the view section.
|
||||
*/
|
||||
export function buildDefaultFormLayout(fields: ViewFieldRec[]): FormLayoutNode {
|
||||
const boxes: FormLayoutNode[] = fields.map(f => {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: 'Field',
|
||||
leaf: f.id(),
|
||||
};
|
||||
});
|
||||
const section = components.Section(...boxes);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: 'Layout',
|
||||
children: [
|
||||
{id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', },
|
||||
{id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', },
|
||||
section,
|
||||
{id: uuidv4(), type: 'Submit'},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
|
||||
defaults(FormView.prototype, BaseView.prototype);
|
||||
Object.assign(FormView.prototype, BackboneEvents);
|
||||
|
||||
@@ -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);
|
||||
// The newly-added section should be given focus.
|
||||
this.viewModel.activeSectionId(result.sectionRef);
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
|
||||
if (AttachedCustomWidgets.guard(val.type)) {
|
||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
||||
}
|
||||
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(sectionRef);
|
||||
}
|
||||
|
||||
this._maybeShowEditCardLayoutTip(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({
|
||||
|
||||
Reference in New Issue
Block a user