(core) Forms feature

Summary:
A new widget type Forms. For now hidden behind GRIST_EXPERIMENTAL_PLUGINS().
This diff contains all the core moving parts as a serves as a base to extend this functionality
further.

Test Plan: New test added

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4130
This commit is contained in:
Jarosław Sadziński
2023-12-12 10:58:20 +01:00
parent 337757d0ba
commit a424450cbe
43 changed files with 4023 additions and 133 deletions

View File

@@ -1,51 +1,103 @@
import {makeT} from 'app/client/lib/localization';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {autoGrow} from 'app/client/ui/forms';
import {textarea} from 'app/client/ui/inputs';
import {textarea, textInput} from 'app/client/ui/inputs';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {CursorPos} from 'app/plugin/GristAPI';
import {dom, fromKo, MultiHolder, styled} from 'grainjs';
import {dom, DomArg, fromKo, MultiHolder, styled} from 'grainjs';
const t = makeT('DescriptionConfig');
export function buildDescriptionConfig(
owner: MultiHolder,
description: KoSaveableObservable<string>,
options: {
cursor: ko.Computed<CursorPos>,
testPrefix: string,
},
) {
owner: MultiHolder,
description: KoSaveableObservable<string>,
options: {
cursor: ko.Computed<CursorPos>,
testPrefix: string,
},
) {
// We will listen to cursor position and force a blur event on
// the text input, which will trigger save before the column observable
// will change its value.
// Otherwise, blur will be invoked after column change and save handler will
// update a different column.
let editor: HTMLTextAreaElement | undefined;
owner.autoDispose(
options.cursor.subscribe(() => {
editor?.blur();
})
);
// We will listen to cursor position and force a blur event on
// the text input, which will trigger save before the column observable
// will change its value.
// Otherwise, blur will be invoked after column change and save handler will
// update a different column.
let editor: HTMLTextAreaElement | undefined;
owner.autoDispose(
options.cursor.subscribe(() => {
editor?.blur();
})
);
return [
cssLabel(t("DESCRIPTION")),
cssRow(
editor = cssTextArea(fromKo(description),
{ onInput: false },
{ rows: '3' },
dom.on('blur', async (e, elem) => {
await description.saveOnly(elem.value);
}),
testId(`${options.testPrefix}-description`),
autoGrow(fromKo(description))
)
return [
cssLabel(t("DESCRIPTION")),
cssRow(
editor = cssTextArea(fromKo(description),
{ onInput: false },
{ rows: '3' },
dom.on('blur', async (e, elem) => {
await description.saveOnly(elem.value);
}),
testId(`${options.testPrefix}-description`),
autoGrow(fromKo(description))
)
),
];
}
/**
* A generic version of buildDescriptionConfig that can be used for any text input.
*/
export function buildTextInput(
owner: MultiHolder,
options: {
value: KoSaveableObservable<any>,
cursor: ko.Computed<CursorPos>,
label: string,
placeholder?: ko.Computed<string>,
},
...args: DomArg[]
) {
owner.autoDispose(
options.cursor.subscribe(() => {
options.value.save().catch(reportError);
})
);
return [
cssLabel(options.label),
cssRow(
cssTextInput(fromKo(options.value),
dom.on('blur', () => {
return options.value.save();
}),
dom.prop('placeholder', options.placeholder || ''),
...args
),
];
),
];
}
const cssTextInput = styled(textInput, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};
border: 1px solid ${theme.inputBorder};
width: 100%;
outline: none;
border-radius: 3px;
height: 28px;
border-radius: 3px;
padding: 0px 6px;
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
&[readonly] {
background-color: ${theme.inputDisabledBg};
color: ${theme.inputDisabledFg};
}
`);
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};

View File

@@ -1,4 +1,5 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import GridView from 'app/client/components/GridView';
import {makeT} from 'app/client/lib/localization';
import {ColumnRec} from "app/client/models/entities/ColumnRec";
@@ -44,6 +45,36 @@ export function buildAddColumnMenu(gridView: GridView, index?: number) {
];
}
export function getColumnTypes(gristDoc: GristDoc, tableId: string, pure = false) {
const typeNames = [
"Text",
"Numeric",
"Int",
"Bool",
"Date",
`DateTime:${gristDoc.docModel.docInfoRow.timezone()}`,
"Choice",
"ChoiceList",
`Ref:${tableId}`,
`RefList:${tableId}`,
"Attachments"];
return typeNames.map(type => ({type, obj: UserType.typeDefs[type.split(':')[0]]}))
.map((ct): { displayName: string, colType: string, testIdName: string, icon: IconName | undefined } => ({
displayName: t(ct.obj.label),
colType: ct.type,
testIdName: ct.obj.label.toLowerCase().replace(' ', '-'),
icon: ct.obj.icon
})).map(ct => {
if (!pure) { return ct; }
else {
return {
...ct,
colType: ct.colType.split(':')[0]
};
}
});
}
function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomElementArg[] {
function buildEmptyNewColumMenuItem() {
return menuItem(
@@ -56,24 +87,7 @@ function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomEle
}
function BuildNewColumnWithTypeSubmenu() {
const columnTypes = [
"Text",
"Numeric",
"Int",
"Bool",
"Date",
`DateTime:${gridView.gristDoc.docModel.docInfoRow.timezone()}`,
"Choice",
"ChoiceList",
`Ref:${gridView.tableModel.tableMetaRow.tableId()}`,
`RefList:${gridView.tableModel.tableMetaRow.tableId()}`,
"Attachments"].map(type => ({type, obj: UserType.typeDefs[type.split(':')[0]]}))
.map((ct): { displayName: string, colType: string, testIdName: string, icon: IconName | undefined } => ({
displayName: t(ct.obj.label),
colType: ct.type,
testIdName: ct.obj.label.toLowerCase().replace(' ', '-'),
icon: ct.obj.icon
}));
const columnTypes = getColumnTypes(gridView.gristDoc, gridView.tableModel.tableMetaRow.tableId());
return menuItemSubmenu(
(ctl) => [

View File

@@ -3,7 +3,7 @@ import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
import {GRIST_FORMS_FEATURE, PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {linkId, NoLink} from 'app/client/ui/selectBy';
import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
@@ -35,7 +35,7 @@ import without = require('lodash/without');
const t = makeT('PageWidgetPicker');
type TableId = number|'New Table'|null;
type TableRef = number|'New Table'|null;
// Describes a widget selection.
export interface IPageWidget {
@@ -44,7 +44,7 @@ export interface IPageWidget {
type: IWidgetType;
// The table (one of the listed tables or 'New Table')
table: TableId;
table: TableRef;
// Whether to summarize the table (not available for "New Table").
summarize: boolean;
@@ -89,22 +89,26 @@ export interface IOptions extends ISelectOptions {
const testId = makeTestId('test-wselect-');
function maybeForms(): Array<'form'> {
return GRIST_FORMS_FEATURE() ? ['form'] : [];
}
// The picker disables some choices that do not make much sense. This function return the list of
// compatible types given the tableId and whether user is creating a new page or not.
function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] {
function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
if (tableId !== 'New Table') {
return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar'];
return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', ...maybeForms()];
} else if (isNewPage) {
// New view + new table means we'll be switching to the primary view.
return ['record'];
return ['record', ...maybeForms()];
} else {
// The type 'chart' makes little sense when creating a new table.
return ['record', 'single', 'detail'];
return ['record', 'single', 'detail', ...maybeForms()];
}
}
// Whether table and type make for a valid selection whether the user is creating a new page or not.
function isValidSelection(table: TableId, type: IWidgetType, isNewPage: boolean|undefined) {
function isValidSelection(table: TableRef, type: IWidgetType, isNewPage: boolean|undefined) {
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
}
@@ -262,7 +266,7 @@ const permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS
const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=>
registeredCustomWidgets.includes(a));
const sectionTypes: IWidgetType[] = [
'record', 'single', 'detail', 'chart', ...finalListOfCustomWidgetToShow, 'custom'
'record', 'single', 'detail', ...maybeForms(), 'chart', ...finalListOfCustomWidgetToShow, 'custom'
];
@@ -425,7 +429,7 @@ export class PageWidgetSelect extends Disposable {
this._value.type.set(type);
}
private _selectTable(tid: TableId) {
private _selectTable(tid: TableRef) {
if (tid !== this._value.table.get()) {
this._value.link.set(NoLink);
}
@@ -437,7 +441,7 @@ export class PageWidgetSelect extends Disposable {
return el.classList.contains(cssEntry.className + '-selected');
}
private _selectPivot(tid: TableId, pivotEl: HTMLElement) {
private _selectPivot(tid: TableRef, pivotEl: HTMLElement) {
if (this._isSelected(pivotEl)) {
this._closeSummarizePanel();
} else {
@@ -456,7 +460,7 @@ export class PageWidgetSelect extends Disposable {
this._value.columns.set(newIds);
}
private _isTypeDisabled(type: IWidgetType, table: TableId) {
private _isTypeDisabled(type: IWidgetType, table: TableRef) {
if (table === null) {
return false;
}

View File

@@ -15,6 +15,7 @@
*/
import * as commands from 'app/client/components/commands';
import {HiddenQuestionConfig} from 'app/client/components/Forms/HiddenQuestionConfig';
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import {EmptyFilterState} from "app/client/components/LinkingState";
import {RefSelect} from 'app/client/components/RefSelect';
@@ -26,7 +27,7 @@ import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs
import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {buildDescriptionConfig, buildTextInput} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
@@ -263,6 +264,18 @@ export class RightPanel extends Disposable {
// Builder for the reference display column multiselect.
const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder});
// The original selected field model.
const fieldRef = owner.autoDispose(ko.pureComputed(() => {
return ((fieldBuilder()?.field)?.id()) ?? 0;
}));
const selectedField = owner.autoDispose(docModel.viewFields.createFloatingRowModel(fieldRef));
// For forms we will show some extra options.
const isForm = owner.autoDispose(ko.computed(() => {
const vs = this._gristDoc.viewModel.activeSection();
return vs.parentKey() === 'form';
}));
// build cursor position observable
const cursor = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
@@ -276,6 +289,14 @@ export class RightPanel extends Disposable {
cssSection(
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
),
dom.maybe(isForm, () => [
cssSection(
dom.create(buildTextInput, {
cursor, label: 'Question', value: selectedField.question,
placeholder: selectedField.origLabel
}),
),
]),
cssSection(
dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }),
),
@@ -430,6 +451,19 @@ export class RightPanel extends Disposable {
cssSeparator(dom.hide(activeSection.isRecordCard)),
dom.domComputed(use => {
const vs = use(activeSection.viewInstance);
if (!vs || use(activeSection.parentKey) !== 'form') { return null; }
return [
cssRow(
primaryButton(t("Reset form"), dom.on('click', () => {
activeSection.layoutSpecObj.setAndSave(null).catch(reportError);
})),
cssRow.cls('-top-space')
),
];
}),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
cssLabel(t("Theme")),
dom('div',
@@ -486,11 +520,16 @@ export class RightPanel extends Disposable {
use(hasCustomMapping) ||
use(this._pageWidgetType) === 'chart' ||
use(activeSection.isRaw)
),
) && use(activeSection.parentKey) !== 'form',
() => [
cssSeparator(),
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
]),
dom.maybe(use => use(activeSection.parentKey) === 'form', () => [
cssSeparator(),
dom.create(HiddenQuestionConfig, activeSection),
]),
]);
}

View File

@@ -276,14 +276,7 @@ export class VisibleFieldsConfig extends Disposable {
}
public async removeField(field: IField) {
const existing = this._section.viewFields.peek().peek()
.find((f) => f.column.peek().getRowId() === field.origCol.peek().id.peek());
if (!existing) {
return;
}
const id = existing.id.peek();
const action = ['RemoveRecord', id];
await this._gristDoc.docModel.viewFields.sendTableAction(action);
await this._section.removeField(field.getRowId());
}
public async addField(column: IField, nextField: ViewFieldRec|null = null) {

View File

@@ -39,9 +39,9 @@ export const cssInput = styled('input', `
/**
* Builds a text input that updates `obs` as you type.
*/
export function textInput(obs: Observable<string>, ...args: DomElementArg[]): HTMLInputElement {
export function textInput(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
return cssInput(
dom.prop('value', obs),
dom.prop('value', u => u(obs) || ''),
dom.on('input', (_e, elem) => obs.set(elem.value)),
...args,
);
@@ -67,4 +67,4 @@ export function textarea(
options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null,
dom.on('change', (e, elem) => setValue(elem)),
);
}
}

View File

@@ -7,6 +7,7 @@ export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
['single', {label: 'Card', icon: 'TypeCard'}],
['detail', {label: 'Card List', icon: 'TypeCardList'}],
['chart', {label: 'Chart', icon: 'TypeChart'}],
['form', {label: 'Form', icon: 'Board'}],
['custom', {label: 'Custom', icon: 'TypeCustom'}],
['custom.calendar', {label: 'Calendar', icon: 'TypeCalendar'}],
]);