mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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};
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'}],
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user