2024-03-20 14:51:59 +00:00
|
|
|
import {FormLayoutNode, SELECT_PLACEHOLDER} from 'app/client/components/FormRenderer';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {buildEditor} from 'app/client/components/Forms/Editor';
|
2023-12-12 09:58:20 +00:00
|
|
|
import {FormView} from 'app/client/components/Forms/FormView';
|
2024-01-23 20:52:57 +00:00
|
|
|
import {BoxModel, ignoreClick} from 'app/client/components/Forms/Model';
|
2024-01-18 17:23:50 +00:00
|
|
|
import * as css from 'app/client/components/Forms/styles';
|
|
|
|
import {stopEvent} from 'app/client/lib/domUtils';
|
2024-04-11 06:50:30 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {refRecord} from 'app/client/models/DocModel';
|
2024-04-11 06:50:30 +00:00
|
|
|
import {
|
|
|
|
FormNumberFormat,
|
|
|
|
FormOptionsAlignment,
|
|
|
|
FormOptionsSortOrder,
|
|
|
|
FormSelectFormat,
|
|
|
|
FormTextFormat,
|
|
|
|
FormToggleFormat,
|
|
|
|
} from 'app/client/ui/FormAPI';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {autoGrow} from 'app/client/ui/forms';
|
2024-04-11 06:50:30 +00:00
|
|
|
import {cssCheckboxSquare, cssLabel, squareCheckbox} from 'app/client/ui2018/checkbox';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {colors} from 'app/client/ui2018/cssVars';
|
2024-04-11 06:50:30 +00:00
|
|
|
import {cssRadioInput} from 'app/client/ui2018/radio';
|
2024-03-20 14:51:59 +00:00
|
|
|
import {isBlankValue} from 'app/common/gristTypes';
|
2024-01-24 16:14:34 +00:00
|
|
|
import {Constructor, not} from 'app/common/gutil';
|
2024-01-18 17:23:50 +00:00
|
|
|
import {
|
|
|
|
BindableValue,
|
|
|
|
Computed,
|
|
|
|
Disposable,
|
|
|
|
dom,
|
|
|
|
DomContents,
|
|
|
|
DomElementArg,
|
|
|
|
IDomArgs,
|
|
|
|
makeTestId,
|
|
|
|
MultiHolder,
|
|
|
|
observable,
|
|
|
|
Observable,
|
2024-04-11 06:50:30 +00:00
|
|
|
toKo,
|
2024-01-18 17:23:50 +00:00
|
|
|
} from 'grainjs';
|
2023-12-12 09:58:20 +00:00
|
|
|
import * as ko from 'knockout';
|
|
|
|
|
|
|
|
const testId = makeTestId('test-forms-');
|
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
const t = makeT('FormView');
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
/**
|
2024-01-18 17:23:50 +00:00
|
|
|
* Container class for all fields.
|
2023-12-12 09:58:20 +00:00
|
|
|
*/
|
|
|
|
export class FieldModel extends BoxModel {
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
/**
|
|
|
|
* Edit mode, (only one element can be in edit mode in the form editor).
|
|
|
|
*/
|
|
|
|
public edit = Observable.create(this, false);
|
2023-12-12 09:58:20 +00:00
|
|
|
public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)()));
|
2024-01-18 17:23:50 +00:00
|
|
|
public field = refRecord(this.view.gristDoc.docModel.viewFields, this.fieldRef);
|
|
|
|
public colId = Computed.create(this, (use) => use(use(this.field).colId));
|
|
|
|
public column = Computed.create(this, (use) => use(use(this.field).column));
|
2024-01-24 16:14:34 +00:00
|
|
|
public required: Computed<boolean>;
|
2023-12-12 09:58:20 +00:00
|
|
|
public question = Computed.create(this, (use) => {
|
2024-01-18 17:23:50 +00:00
|
|
|
const field = use(this.field);
|
|
|
|
if (field.isDisposed() || use(field.id) === 0) { return ''; }
|
|
|
|
return use(field.question) || use(field.origLabel);
|
2023-12-12 09:58:20 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
public description = Computed.create(this, (use) => {
|
2024-01-18 17:23:50 +00:00
|
|
|
const field = use(this.field);
|
|
|
|
return use(field.description);
|
2023-12-12 09:58:20 +00:00
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
/**
|
|
|
|
* Column type of the field.
|
|
|
|
*/
|
2023-12-12 09:58:20 +00:00
|
|
|
public colType = Computed.create(this, (use) => {
|
2024-01-18 17:23:50 +00:00
|
|
|
const field = use(this.field);
|
|
|
|
return use(use(field.column).pureType);
|
2023-12-12 09:58:20 +00:00
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
/**
|
|
|
|
* Field row id.
|
|
|
|
*/
|
2023-12-12 09:58:20 +00:00
|
|
|
public get leaf() {
|
2024-01-23 20:52:57 +00:00
|
|
|
return this.prop('leaf') as Observable<number>;
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
/**
|
|
|
|
* A renderer of question instance.
|
|
|
|
*/
|
2023-12-12 09:58:20 +00:00
|
|
|
public renderer = Computed.create(this, (use) => {
|
|
|
|
const ctor = fieldConstructor(use(this.colType));
|
2024-01-18 17:23:50 +00:00
|
|
|
const instance = new ctor(this);
|
2023-12-12 09:58:20 +00:00
|
|
|
use.owner.autoDispose(instance);
|
|
|
|
return instance;
|
|
|
|
});
|
|
|
|
|
2024-02-21 19:22:01 +00:00
|
|
|
constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {
|
2023-12-12 09:58:20 +00:00
|
|
|
super(box, parent, view);
|
2024-01-18 17:23:50 +00:00
|
|
|
|
2024-01-24 16:14:34 +00:00
|
|
|
this.required = Computed.create(this, (use) => {
|
|
|
|
const field = use(this.field);
|
|
|
|
return Boolean(use(field.widgetOptionsJson.prop('formRequired')));
|
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
this.question.onWrite(value => {
|
|
|
|
this.field.peek().question.setAndSave(value).catch(reportError);
|
|
|
|
});
|
|
|
|
|
|
|
|
this.autoDispose(
|
|
|
|
this.selected.addListener((now, then) => {
|
|
|
|
if (!now && then) {
|
|
|
|
setImmediate(() => !this.edit.isDisposed() && this.edit.set(false));
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public override render(...args: IDomArgs<HTMLElement>): HTMLElement {
|
|
|
|
// Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).
|
|
|
|
const save = (value: string) => {
|
|
|
|
value = value?.trim();
|
|
|
|
// If question is empty or same as original, don't save.
|
|
|
|
if (!value || value === this.field.peek().question()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.field.peek().question.setAndSave(value).catch(reportError);
|
|
|
|
};
|
|
|
|
const overlay = Observable.create(null, true);
|
|
|
|
|
|
|
|
const content = dom.domComputed(this.renderer, (r) => r.buildDom({
|
|
|
|
edit: this.edit,
|
|
|
|
overlay,
|
|
|
|
onSave: save,
|
2024-01-24 16:14:34 +00:00
|
|
|
}));
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
return buildEditor({
|
|
|
|
box: this,
|
|
|
|
overlay,
|
|
|
|
removeIcon: 'CrossBig',
|
|
|
|
removeTooltip: 'Hide',
|
|
|
|
editMode: this.edit,
|
|
|
|
content,
|
|
|
|
},
|
|
|
|
dom.on('dblclick', () => this.selected.get() && this.edit.set(true)),
|
2024-01-24 16:14:34 +00:00
|
|
|
...args
|
2023-12-12 09:58:20 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public async deleteSelf() {
|
2024-01-18 17:23:50 +00:00
|
|
|
const rowId = this.field.peek().id.peek();
|
2023-12-12 09:58:20 +00:00
|
|
|
const view = this.view;
|
2024-01-18 17:23:50 +00:00
|
|
|
const root = this.root();
|
2023-12-12 09:58:20 +00:00
|
|
|
this.removeSelf();
|
|
|
|
// The order here matters for undo.
|
2024-01-18 17:23:50 +00:00
|
|
|
await root.save(async () => {
|
|
|
|
// Make sure to save first layout without this field, otherwise the undo won't work properly.
|
|
|
|
await root.save();
|
|
|
|
// We are disposed at this point, be still can access the view.
|
|
|
|
if (rowId) {
|
|
|
|
await view.viewSection.removeField(rowId);
|
|
|
|
}
|
|
|
|
});
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
export abstract class Question extends Disposable {
|
2024-04-11 06:50:30 +00:00
|
|
|
protected field = this.model.field;
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
constructor(public model: FieldModel) {
|
2023-12-12 09:58:20 +00:00
|
|
|
super();
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public buildDom(props: {
|
|
|
|
edit: Observable<boolean>,
|
|
|
|
overlay: Observable<boolean>,
|
|
|
|
onSave: (value: string) => void,
|
|
|
|
}, ...args: IDomArgs<HTMLElement>) {
|
2024-01-24 16:14:34 +00:00
|
|
|
return css.cssQuestion(
|
2024-01-18 17:23:50 +00:00
|
|
|
testId('question'),
|
|
|
|
testType(this.model.colType),
|
2024-04-11 06:50:30 +00:00
|
|
|
this.renderLabel(props),
|
2024-01-18 17:23:50 +00:00
|
|
|
this.renderInput(),
|
2024-01-24 16:14:34 +00:00
|
|
|
css.cssQuestion.cls('-required', this.model.required),
|
2024-01-18 17:23:50 +00:00
|
|
|
...args
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public abstract renderInput(): DomContents;
|
|
|
|
|
|
|
|
protected renderLabel(props: {
|
|
|
|
edit: Observable<boolean>,
|
|
|
|
onSave: (value: string) => void,
|
|
|
|
}, ...args: DomElementArg[]) {
|
|
|
|
const {edit, onSave} = props;
|
|
|
|
|
|
|
|
const scope = new MultiHolder();
|
|
|
|
|
|
|
|
// When in edit, we will update a copy of the question.
|
|
|
|
const draft = Observable.create(scope, this.model.question.get());
|
|
|
|
scope.autoDispose(
|
|
|
|
this.model.question.addListener(q => draft.set(q)),
|
|
|
|
);
|
|
|
|
const controller = Computed.create(scope, (use) => use(draft));
|
|
|
|
controller.onWrite(value => {
|
|
|
|
if (this.isDisposed() || draft.isDisposed()) { return; }
|
|
|
|
if (!edit.get()) { return; }
|
|
|
|
draft.set(value);
|
|
|
|
});
|
|
|
|
|
|
|
|
// Wire up save method.
|
|
|
|
const saveDraft = (ok: boolean) => {
|
|
|
|
if (this.isDisposed() || draft.isDisposed()) { return; }
|
|
|
|
if (!ok || !edit.get() || !controller.get()) {
|
|
|
|
controller.set(this.model.question.get());
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
onSave(controller.get());
|
|
|
|
};
|
|
|
|
let element: HTMLTextAreaElement;
|
|
|
|
|
|
|
|
scope.autoDispose(
|
|
|
|
props.edit.addListener((now, then) => {
|
|
|
|
if (now && !then) {
|
|
|
|
// When we go into edit mode, we copy the question into draft.
|
|
|
|
draft.set(this.model.question.get());
|
|
|
|
// And focus on the element.
|
|
|
|
setTimeout(() => {
|
|
|
|
element?.focus();
|
|
|
|
element?.select();
|
|
|
|
}, 10);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
|
|
|
|
return [
|
|
|
|
dom.autoDispose(scope),
|
2024-01-24 16:14:34 +00:00
|
|
|
css.cssRequiredWrapper(
|
2024-01-18 17:23:50 +00:00
|
|
|
testId('label'),
|
2024-01-24 16:14:34 +00:00
|
|
|
// When in edit - hide * and change display from grid to display
|
2024-04-11 06:50:30 +00:00
|
|
|
css.cssRequiredWrapper.cls('-required', use => use(this.model.required) && !use(this.model.edit)),
|
2024-01-24 16:14:34 +00:00
|
|
|
dom.maybe(props.edit, () => [
|
|
|
|
element = css.cssEditableLabel(
|
|
|
|
controller,
|
|
|
|
{onInput: true},
|
|
|
|
// Attach common Enter,Escape, blur handlers.
|
|
|
|
css.saveControls(edit, saveDraft),
|
|
|
|
// Autoselect whole text when mounted.
|
|
|
|
// Auto grow for textarea.
|
|
|
|
autoGrow(controller),
|
|
|
|
// Enable normal menu.
|
|
|
|
dom.on('contextmenu', stopEvent),
|
|
|
|
dom.style('resize', 'none'),
|
|
|
|
css.cssEditableLabel.cls('-edit'),
|
|
|
|
testId('label-editor'),
|
|
|
|
),
|
|
|
|
]),
|
|
|
|
dom.maybe(not(props.edit), () => [
|
|
|
|
css.cssRenderedLabel(
|
|
|
|
dom.text(controller),
|
|
|
|
testId('label-rendered'),
|
|
|
|
),
|
|
|
|
]),
|
2024-01-18 17:23:50 +00:00
|
|
|
// When selected, we want to be able to edit the label by clicking it
|
|
|
|
// so we need to make it relative and z-indexed.
|
|
|
|
dom.style('position', u => u(this.model.selected) ? 'relative' : 'static'),
|
|
|
|
dom.style('z-index', '2'),
|
|
|
|
dom.on('click', (ev) => {
|
|
|
|
if (this.model.selected.get() && !props.edit.get()) {
|
|
|
|
props.edit.set(true);
|
|
|
|
ev.stopPropagation();
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
...args,
|
|
|
|
),
|
|
|
|
];
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class TextModel extends Question {
|
2024-04-11 06:50:30 +00:00
|
|
|
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';
|
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public renderInput() {
|
2024-04-11 06:50:30 +00:00
|
|
|
return dom.domComputed(this._format, (format) => {
|
|
|
|
switch (format) {
|
|
|
|
case 'text': {
|
|
|
|
return this._renderTextInput();
|
|
|
|
}
|
|
|
|
case 'spinner': {
|
|
|
|
return this._renderSpinnerInput();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private _renderTextInput() {
|
2024-01-18 17:23:50 +00:00
|
|
|
return css.cssInput(
|
2024-04-11 06:50:30 +00:00
|
|
|
dom.prop('name', u => u(u(this.field).colId)),
|
2023-12-12 09:58:20 +00:00
|
|
|
{type: 'text', tabIndex: "-1"},
|
|
|
|
);
|
|
|
|
}
|
2024-04-11 06:50:30 +00:00
|
|
|
|
|
|
|
private _renderSpinnerInput() {
|
|
|
|
return css.cssSpinner(observable(''), {});
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
class ChoiceModel extends Question {
|
2024-04-11 06:50:30 +00:00
|
|
|
protected choices: Computed<string[]>;
|
|
|
|
|
|
|
|
protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {
|
|
|
|
const field = use(this.field);
|
|
|
|
return use(field.widgetOptionsJson.prop('formOptionsAlignment')) ?? 'vertical';
|
2024-01-23 20:52:57 +00:00
|
|
|
});
|
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
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() {
|
2024-01-18 17:23:50 +00:00
|
|
|
return css.cssSelect(
|
2023-12-12 09:58:20 +00:00
|
|
|
{tabIndex: "-1"},
|
|
|
|
ignoreClick,
|
2024-04-11 06:50:30 +00:00
|
|
|
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,
|
|
|
|
)),
|
2023-12-12 09:58:20 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-23 20:52:57 +00:00
|
|
|
class ChoiceListModel extends ChoiceModel {
|
2024-03-20 14:51:59 +00:00
|
|
|
private _choices = Computed.create(this, use => {
|
|
|
|
// Support for 30 choices. TODO: make limit dynamic.
|
|
|
|
return use(this.choices).slice(0, 30);
|
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public renderInput() {
|
2024-04-11 06:50:30 +00:00
|
|
|
const field = this.field;
|
|
|
|
return css.cssCheckboxList(
|
|
|
|
css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
2024-01-18 17:23:50 +00:00
|
|
|
dom.prop('name', use => use(use(field).colId)),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom.forEach(this._choices, (choice) => css.cssCheckboxLabel(
|
2024-04-11 06:50:30 +00:00
|
|
|
css.cssCheckboxLabel.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
|
|
|
cssCheckboxSquare({type: 'checkbox'}),
|
|
|
|
choice,
|
2023-12-12 09:58:20 +00:00
|
|
|
)),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom.maybe(use => use(this._choices).length === 0, () => [
|
2024-04-11 06:50:30 +00:00
|
|
|
css.cssWarningMessage(css.cssWarningIcon('Warning'), t('No choices configured')),
|
2023-12-12 09:58:20 +00:00
|
|
|
]),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class BoolModel extends Question {
|
2024-04-11 06:50:30 +00:00
|
|
|
private _format = Computed.create<FormToggleFormat>(this, (use) => {
|
|
|
|
const field = use(this.field);
|
|
|
|
return use(field.widgetOptionsJson.prop('formToggleFormat')) ?? 'switch';
|
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public override buildDom(props: {
|
|
|
|
edit: Observable<boolean>,
|
|
|
|
overlay: Observable<boolean>,
|
|
|
|
question: Observable<string>,
|
|
|
|
onSave: () => void,
|
|
|
|
}) {
|
2024-01-24 16:14:34 +00:00
|
|
|
return css.cssQuestion(
|
2024-01-18 17:23:50 +00:00
|
|
|
testId('question'),
|
|
|
|
testType(this.model.colType),
|
2024-04-11 06:50:30 +00:00
|
|
|
css.cssToggle(
|
2024-01-18 17:23:50 +00:00
|
|
|
this.renderInput(),
|
2024-01-24 16:14:34 +00:00
|
|
|
this.renderLabel(props, css.cssLabelInline.cls('')),
|
2023-12-12 09:58:20 +00:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
2024-04-11 06:50:30 +00:00
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public override renderInput() {
|
2024-04-11 06:50:30 +00:00
|
|
|
return dom.domComputed(this._format, (format) => {
|
|
|
|
if (format === 'switch') {
|
|
|
|
return this._renderSwitchInput();
|
|
|
|
} else {
|
|
|
|
return this._renderCheckboxInput();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private _renderSwitchInput() {
|
|
|
|
return css.cssWidgetSwitch(
|
2024-01-18 17:23:50 +00:00
|
|
|
dom.style('--grist-actual-cell-color', colors.lightGreen.toString()),
|
2024-04-11 06:50:30 +00:00
|
|
|
dom.cls('switch_transition'),
|
2024-01-18 17:23:50 +00:00
|
|
|
dom('div.switch_slider'),
|
|
|
|
dom('div.switch_circle'),
|
|
|
|
);
|
|
|
|
}
|
2024-04-11 06:50:30 +00:00
|
|
|
|
|
|
|
private _renderCheckboxInput() {
|
|
|
|
return cssLabel(
|
|
|
|
cssCheckboxSquare({type: 'checkbox'}),
|
|
|
|
);
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
class DateModel extends Question {
|
2024-01-18 17:23:50 +00:00
|
|
|
public renderInput() {
|
2023-12-12 09:58:20 +00:00
|
|
|
return dom('div',
|
2024-01-18 17:23:50 +00:00
|
|
|
css.cssInput(
|
|
|
|
dom.prop('name', this.model.colId),
|
2024-04-11 06:50:30 +00:00
|
|
|
{type: 'date', style: 'margin-right: 5px;'},
|
|
|
|
),
|
2023-12-12 09:58:20 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class DateTimeModel extends Question {
|
2024-01-18 17:23:50 +00:00
|
|
|
public renderInput() {
|
2023-12-12 09:58:20 +00:00
|
|
|
return dom('div',
|
2024-01-18 17:23:50 +00:00
|
|
|
css.cssInput(
|
|
|
|
dom.prop('name', this.model.colId),
|
2024-04-11 06:50:30 +00:00
|
|
|
{type: 'datetime-local', style: 'margin-right: 5px;'},
|
2023-12-12 09:58:20 +00:00
|
|
|
),
|
|
|
|
dom.style('width', '100%'),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
class RefListModel extends Question {
|
2024-04-11 06:50:30 +00:00
|
|
|
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();
|
|
|
|
}
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
public renderInput() {
|
2024-04-11 06:50:30 +00:00
|
|
|
return css.cssCheckboxList(
|
|
|
|
css.cssCheckboxList.cls('-horizontal', use => use(this.alignment) === 'horizontal'),
|
2024-01-18 17:23:50 +00:00
|
|
|
dom.prop('name', this.model.colId),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom.forEach(this.options, (option) => css.cssCheckboxLabel(
|
2024-01-24 09:58:19 +00:00
|
|
|
squareCheckbox(observable(false)),
|
2024-03-20 14:51:59 +00:00
|
|
|
option.label,
|
2024-01-18 17:23:50 +00:00
|
|
|
)),
|
2024-03-20 14:51:59 +00:00
|
|
|
dom.maybe(use => use(this.options).length === 0, () => [
|
2024-04-11 06:50:30 +00:00
|
|
|
css.cssWarningMessage(
|
|
|
|
css.cssWarningIcon('Warning'),
|
|
|
|
t('No values in show column of referenced table'),
|
|
|
|
),
|
2024-01-18 17:23:50 +00:00
|
|
|
]),
|
2024-04-11 06:50:30 +00:00
|
|
|
);
|
2024-01-18 17:23:50 +00:00
|
|
|
}
|
|
|
|
|
2024-03-20 14:51:59 +00:00
|
|
|
private _getOptions() {
|
2024-01-18 17:23:50 +00:00
|
|
|
const tableId = Computed.create(this, use => {
|
|
|
|
const refTable = use(use(this.model.column).refTable);
|
|
|
|
return refTable ? use(refTable.tableId) : '';
|
|
|
|
});
|
|
|
|
|
|
|
|
const colId = Computed.create(this, use => {
|
|
|
|
const dispColumnIdObs = use(use(this.model.column).visibleColModel);
|
2024-04-11 06:50:30 +00:00
|
|
|
return use(dispColumnIdObs.colId) || 'id';
|
2024-01-18 17:23:50 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
const observer = this.model.view.gristDoc.columnObserver(this, tableId, colId);
|
|
|
|
|
|
|
|
return Computed.create(this, use => {
|
2024-04-11 06:50:30 +00:00
|
|
|
const sort = use(this._sortOrder);
|
|
|
|
const values = use(observer)
|
2024-03-20 14:51:59 +00:00
|
|
|
.filter(([_id, value]) => !isBlankValue(value))
|
2024-04-11 06:50:30 +00:00
|
|
|
.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);
|
2024-01-18 17:23:50 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class RefModel extends RefListModel {
|
2024-04-11 06:50:30 +00:00
|
|
|
private _format = Computed.create<FormSelectFormat>(this, (use) => {
|
|
|
|
const field = use(this.field);
|
|
|
|
return use(field.widgetOptionsJson.prop('formSelectFormat')) ?? 'select';
|
|
|
|
});
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public renderInput() {
|
2024-04-11 06:50:30 +00:00
|
|
|
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() {
|
2024-01-18 17:23:50 +00:00
|
|
|
return css.cssSelect(
|
|
|
|
{tabIndex: "-1"},
|
|
|
|
ignoreClick,
|
|
|
|
dom.prop('name', this.model.colId),
|
2024-04-11 06:50:30 +00:00
|
|
|
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,
|
|
|
|
)),
|
2024-01-18 17:23:50 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
const AnyModel = TextModel;
|
|
|
|
|
2024-04-11 06:50:30 +00:00
|
|
|
// Attachments are not currently supported.
|
|
|
|
const AttachmentsModel = TextModel;
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
function fieldConstructor(type: string): Constructor<Question> {
|
|
|
|
switch (type) {
|
|
|
|
case 'Any': return AnyModel;
|
|
|
|
case 'Bool': return BoolModel;
|
|
|
|
case 'Choice': return ChoiceModel;
|
|
|
|
case 'ChoiceList': return ChoiceListModel;
|
|
|
|
case 'Date': return DateModel;
|
|
|
|
case 'DateTime': return DateTimeModel;
|
2024-04-11 06:50:30 +00:00
|
|
|
case 'Int': return NumericModel;
|
2023-12-12 09:58:20 +00:00
|
|
|
case 'Numeric': return NumericModel;
|
|
|
|
case 'Ref': return RefModel;
|
|
|
|
case 'RefList': return RefListModel;
|
|
|
|
case 'Attachments': return AttachmentsModel;
|
|
|
|
default: return TextModel;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a hidden input element with element type. Used in tests.
|
|
|
|
*/
|
|
|
|
function testType(value: BindableValue<string>) {
|
|
|
|
return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
|
|
|
|
}
|