(core) Forms improvements

Summary:
Forms improvements and following new design
- New headers
- New UI
- New right panel options

Test Plan: Tests updated

Reviewers: georgegevoian, dsagal

Reviewed By: georgegevoian

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D4158
This commit is contained in:
Jarosław Sadziński
2024-01-18 18:23:50 +01:00
parent b82209b458
commit 0aad09a4ed
55 changed files with 3468 additions and 1410 deletions

View File

@@ -52,18 +52,20 @@ export function buildDescriptionConfig(
export function buildTextInput(
owner: MultiHolder,
options: {
value: KoSaveableObservable<any>,
cursor: ko.Computed<CursorPos>,
label: string,
value: KoSaveableObservable<any>,
cursor?: ko.Computed<CursorPos>,
placeholder?: ko.Computed<string>,
},
...args: DomArg[]
) {
owner.autoDispose(
options.cursor.subscribe(() => {
options.value.save().catch(reportError);
})
);
if (options.cursor) {
owner.autoDispose(
options.cursor.subscribe(() => {
options.value.save().catch(reportError);
})
);
}
return [
cssLabel(options.label),
cssRow(
@@ -84,7 +86,6 @@ const cssTextInput = styled(textInput, `
border: 1px solid ${theme.inputBorder};
width: 100%;
outline: none;
border-radius: 3px;
height: 28px;
border-radius: 3px;
padding: 0px 6px;

View File

@@ -59,6 +59,15 @@ export interface IPageWidget {
section: number;
}
export const DefaultPageWidget: () => IPageWidget = () => ({
type: 'record',
table: null,
summarize: false,
columns: [],
link: NoLink,
section: 0,
});
// Creates a IPageWidget from a ViewSectionRec.
export function toPageWidget(section: ViewSectionRec): IPageWidget {
const link = linkId({

View File

@@ -15,7 +15,9 @@
*/
import * as commands from 'app/client/components/commands';
import {HiddenQuestionConfig} from 'app/client/components/Forms/HiddenQuestionConfig';
import {FieldModel} from 'app/client/components/Forms/Field';
import {FormView} from 'app/client/components/Forms/FormView';
import {UnmappedFieldsConfig} from 'app/client/components/Forms/UnmappedFieldsConfig';
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import {EmptyFilterState} from "app/client/components/LinkingState";
import {RefSelect} from 'app/client/components/RefSelect';
@@ -27,9 +29,11 @@ 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, buildTextInput} from 'app/client/ui/DescriptionConfig';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {autoGrow} from 'app/client/ui/forms';
import {GridOptions} from 'app/client/ui/GridOptions';
import {textarea} from 'app/client/ui/inputs';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig";
import {cssLabel} from 'app/client/ui/RightPanelStyles';
@@ -37,6 +41,8 @@ import {linkId, selectBy} from 'app/client/ui/selectBy';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {widgetTypesMap} from "app/client/ui/widgetTypesMap";
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {IconName} from 'app/client/ui2018/IconList';
@@ -56,6 +62,7 @@ import {
DomContents,
DomElementArg,
DomElementMethod,
fromKo,
IDomComponent,
MultiHolder,
Observable,
@@ -74,7 +81,7 @@ const t = makeT('RightPanel');
const TopTab = StringUnion("pageWidget", "field");
// Represents a subtab of pageWidget in the right side-pane.
const PageSubTab = StringUnion("widget", "sortAndFilter", "data");
const PageSubTab = StringUnion("widget", "sortAndFilter", "data", "submission");
// Returns the icon and label of a type, default to those associate to 'record' type.
export function getFieldType(widgetType: IWidgetType|null) {
@@ -85,6 +92,7 @@ export function getFieldType(widgetType: IWidgetType|null) {
['single', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}],
['chart', {label: t('Series', { count: 1 }), icon: 'ChartLine', pluralLabel: t('Series', { count: 2 })}],
['custom', {label: t('Columns', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Columns', { count: 2 })}],
['form', {label: t('Fields', { count: 1 }), icon: 'TypeCell', pluralLabel: t('Fields', { count: 2 })}],
]);
return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!;
@@ -111,6 +119,10 @@ export class RightPanel extends Disposable {
return (use(section.parentKey) || null) as IWidgetType;
});
private _isForm = Computed.create(this, (use) => {
return use(this._pageWidgetType) === 'form';
});
// Returns the active section if it's valid, null otherwise.
private _validSection = Computed.create(this, (use) => {
const sec = use(this._gristDoc.viewModel.activeSection);
@@ -135,6 +147,16 @@ export class RightPanel extends Disposable {
sortFilterTabOpen: () => this._openSortFilter(),
dataSelectionTabOpen: () => this._openDataSelection()
}, this, true));
// When a page widget is changed, subType might not be valid anymore, so reset it.
// TODO: refactor sub tabs and navigation using order of the tab.
this.autoDispose(subscribe((use) => {
if (!use(this._isForm) && use(this._subTab) === 'submission') {
setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('sortAndFilter'));
} else if (use(this._isForm) && use(this._subTab) === 'sortAndFilter') {
setImmediate(() => !this._subTab.isDisposed() && this._subTab.set('submission'));
}
}));
}
private _openFieldTab() {
@@ -216,13 +238,27 @@ export class RightPanel extends Disposable {
if (!use(this._isOpen)) { return null; }
const tool = use(this._extraTool);
if (tool) { return tabContentToDom(tool.content); }
const isForm = use(this._isForm);
const topTab = use(this._topTab);
if (topTab === 'field') {
return dom.create(this._buildFieldContent.bind(this));
}
if (topTab === 'pageWidget' && use(this._pageWidgetType)) {
return dom.create(this._buildPageWidgetContent.bind(this));
if (isForm) {
return dom.create(this._buildQuestionContent.bind(this));
} else {
return dom.create(this._buildFieldContent.bind(this));
}
} else if (topTab === 'pageWidget') {
if (isForm) {
return [
dom.create(this._buildPageFormHeader.bind(this)),
dom.create(this._buildPageWidgetContent.bind(this)),
];
} else {
return [
dom.create(this._buildPageWidgetHeader.bind(this)),
dom.create(this._buildPageWidgetContent.bind(this)),
];
}
}
return null;
});
@@ -264,18 +300,6 @@ 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();
@@ -289,14 +313,6 @@ 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" }),
),
@@ -357,7 +373,48 @@ export class RightPanel extends Disposable {
});
}
private _buildPageWidgetContent(_owner: MultiHolder) {
private _buildPageWidgetContent() {
const content = (activeSection: ViewSectionRec, type: typeof PageSubTab.type) => {
switch(type){
case 'widget': return dom.create(this._buildPageWidgetConfig.bind(this), activeSection);
case 'sortAndFilter': return [
dom.create(this._buildPageSortFilterConfig.bind(this)),
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
];
case 'data': return dom.create(this._buildPageDataConfig.bind(this), activeSection);
case 'submission': return dom.create(this._buildPageSubmissionConfig.bind(this), activeSection);
default: return null;
}
};
return dom.domComputed(this._subTab, (subTab) => (
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
content(activeSection, subTab)
)
))
));
}
private _buildPageFormHeader(_owner: MultiHolder) {
return [
cssSubTabContainer(
cssSubTab(t("Configuration"),
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'),
dom.on('click', () => this._subTab.set("widget")),
testId('config-widget')),
cssSubTab(t("Submission"),
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'submission'),
dom.on('click', () => this._subTab.set("submission")),
testId('config-submission')),
cssSubTab(t("Data"),
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'),
dom.on('click', () => this._subTab.set("data")),
testId('config-data')),
),
];
}
private _buildPageWidgetHeader(_owner: MultiHolder) {
return [
cssSubTabContainer(
cssSubTab(t("Widget"),
@@ -373,19 +430,6 @@ export class RightPanel extends Disposable {
dom.on('click', () => this._subTab.set("data")),
testId('config-data')),
),
dom.domComputed(this._subTab, (subTab) => (
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
subTab === 'sortAndFilter' ? [
dom.create(this._buildPageSortFilterConfig.bind(this)),
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
] :
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
null
)
))
))
];
}
@@ -449,21 +493,6 @@ 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',
@@ -526,9 +555,9 @@ export class RightPanel extends Disposable {
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
]),
dom.maybe(use => use(activeSection.parentKey) === 'form', () => [
dom.maybe(this._isForm, () => [
cssSeparator(),
dom.create(HiddenQuestionConfig, activeSection),
dom.create(UnmappedFieldsConfig, activeSection),
]),
]);
}
@@ -733,10 +762,6 @@ export class RightPanel extends Disposable {
});
}
private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
const viewConfigTab = this._createViewConfigTab(owner);
const viewModel = this._gristDoc.viewModel;
@@ -874,6 +899,180 @@ export class RightPanel extends Disposable {
));
}
}
private _buildPageSubmissionConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
// All of those observables are backed by the layout config.
const submitButtonKo = activeSection.layoutSpecObj.prop('submitText');
const toComputed = (obs: typeof submitButtonKo) => {
const result = Computed.create(owner, (use) => use(obs));
result.onWrite(val => obs.setAndSave(val));
return result;
};
const submitButton = toComputed(submitButtonKo);
const successText = toComputed(activeSection.layoutSpecObj.prop('successText'));
const successURL = toComputed(activeSection.layoutSpecObj.prop('successURL'));
const anotherResponse = toComputed(activeSection.layoutSpecObj.prop('anotherResponse'));
const redirection = Observable.create(owner, Boolean(successURL.get()));
owner.autoDispose(redirection.addListener(val => {
if (!val) {
successURL.set(null);
}
}));
owner.autoDispose(successURL.addListener(val => {
if (val) {
redirection.set(true);
}
}));
return [
cssLabel(t("Submit button label")),
cssRow(
cssTextInput(submitButton, (val) => submitButton.set(val)),
),
cssLabel(t("Success text")),
cssRow(
cssTextArea(successText, {onInput: true}, autoGrow(successText)),
),
cssLabel(t("Submit another response")),
cssRow(
labeledSquareCheckbox(anotherResponse, [
t("Display button"),
]),
),
cssLabel(t("Redirection")),
cssRow(
labeledSquareCheckbox(redirection, t('Redirect automatically after submission')),
),
cssRow(
cssTextInput(successURL, (val) => successURL.set(val)),
dom.show(redirection),
),
];
}
private _buildQuestionContent(owner: MultiHolder) {
const fieldBuilder = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
return vsi && vsi.activeFieldBuilder();
}));
const formView = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
return (vsi ?? null) as FormView|null;
}));
const selectedBox = Computed.create(owner, (use) => use(formView) && use(use(formView)!.selectedBox));
const selectedField = Computed.create(owner, (use) => {
const box = use(selectedBox);
if (!box) { return null; }
if (box.type !== 'Field') { return null; }
const fieldBox = box as FieldModel;
return use(fieldBox.field);
});
const selectedColumn = Computed.create(owner, (use) => use(selectedField) && use(use(selectedField)!.origCol));
const hasText = Computed.create(owner, (use) => {
const box = use(selectedBox);
if (!box) { return false; }
switch (box.type) {
case 'Submit':
case 'Paragraph':
case 'Label':
return true;
default:
return false;
}
});
return cssSection(
// Field config.
dom.maybe(selectedField, (field) => {
const requiredField = field.widgetOptionsJson.prop('formRequired');
// V2 thing.
// const hiddenField = field.widgetOptionsJson.prop('formHidden');
const defaultField = field.widgetOptionsJson.prop('formDefault');
const toComputed = (obs: typeof defaultField) => {
const result = Computed.create(null, (use) => use(obs));
result.onWrite(val => obs.setAndSave(val));
return result;
};
return [
cssLabel(t("Field title")),
cssRow(
cssTextInput(
fromKo(field.label),
(val) => field.displayLabel.saveOnly(val),
dom.prop('readonly', use => use(field.disableModify)),
),
),
cssLabel(t("Table column name")),
cssRow(
cssTextInput(
fromKo(field.colId),
(val) => field.column().colId.saveOnly(val),
dom.prop('readonly', use => use(field.disableModify)),
),
),
// TODO: this is for V1 as it requires full cell editor here.
// cssLabel(t("Default field value")),
// cssRow(
// cssTextInput(
// fromKo(defaultField),
// (val) => defaultField.setAndSave(val),
// ),
// ),
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
cssSeparator(),
cssLabel(t("COLUMN TYPE")),
cssSection(
builder.buildSelectTypeDom(),
),
// V2 thing
// cssSection(
// builder.buildSelectWidgetDom(),
// ),
dom.maybe(use => ['Choice', 'ChoiceList', 'Ref', 'RefList'].includes(use(builder.field.pureType)), () => [
cssSection(
builder.buildConfigDom(),
),
]),
]),
cssSeparator(),
cssLabel(t("Field rules")),
cssRow(labeledSquareCheckbox(toComputed(requiredField), t("Required field")),),
// V2 thing
// cssRow(labeledSquareCheckbox(toComputed(hiddenField), t("Hidden field")),),
];
}),
// Box config
dom.maybe(use => use(selectedColumn) ? null : use(selectedBox), (box) => [
cssLabel(dom.text(box.type)),
dom.maybe(hasText, () => [
cssRow(
cssTextArea(
box.prop('text'),
{onInput: true, autoGrow: true},
dom.on('blur', () => box.save().catch(reportError)),
{placeholder: t('Enter text')},
),
),
cssRow(
buttonSelect(box.prop('alignment'), [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
]),
dom.autoDispose(box.prop('alignment').addListener(() => box.save().catch(reportError))),
)
]),
]),
// Default.
dom.maybe(u => !u(selectedColumn) && !u(selectedBox), () => [
cssLabel(t('Layout')),
])
);
}
}
function disabledSection() {
@@ -1115,6 +1314,27 @@ const cssListItem = styled('li', `
padding: 4px 8px;
`);
const cssTextArea = styled(textarea, `
flex: 1 0 auto;
color: ${theme.inputFg};
background-color: ${theme.inputBg};
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
outline: none;
padding: 3px 7px;
/* Make space at least for two lines: size of line * 2 * line height + 2 * padding + border * 2 */
min-height: calc(2em * 1.5 + 2 * 3px + 2px);
line-height: 1.5;
resize: none;
&:disabled {
color: ${theme.inputDisabledFg};
background-color: ${theme.inputDisabledBg};
pointer-events: none;
}
`);
const cssTextInput = styled(textInput, `
flex: 1 0 auto;
color: ${theme.inputFg};

View File

@@ -7,6 +7,7 @@ import {testId} from 'app/client/ui2018/cssVars';
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
import {GristDoc} from 'app/client/components/GristDoc';
import {dom, UseCB} from 'grainjs';
import {WidgetType} from 'app/common/widgetTypes';
const t = makeT('ViewLayoutMenu');
@@ -63,8 +64,11 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
;
};
const isCard = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Card;
const isTable = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Table;
return [
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
dom.maybe(isCard, () => contextMenu),
dom.maybe(showRawData,
() => menuItemLink(
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
@@ -91,6 +95,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
menuItemCmd(allCommands.createForm, t("Create a form"), dom.show(isTable)),
]),
menuDivider(dom.hide(viewSection.isRecordCard)),
@@ -133,7 +138,7 @@ export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: G
)
),
menuDivider(),
menuItemCmd(allCommands.expandSection, t("Add to page"),
menuItemCmd(allCommands.restoreSection, t("Add to page"),
dom.cls('disabled', isReadonly),
testId('section-expand')),
menuItemCmd(allCommands.deleteCollapsedSection, t("Delete widget"),

View File

@@ -160,7 +160,7 @@ export function viewSectionMenu(
cssExpandIconWrapper(
cssSmallIcon('Grow'),
testId('expandSection'),
dom.on('click', () => allCommands.maximizeActiveSection.run()),
dom.on('click', () => allCommands.expandSection.run()),
hoverTooltip('Expand section', {key: 'expandSection'}),
),
)

View File

@@ -84,9 +84,20 @@ function resize(el: HTMLTextAreaElement) {
}
export function autoGrow(text: Observable<string>) {
// If this should autogrow we need to monitor width of this element.
return (el: HTMLTextAreaElement) => {
let width = 0;
const resizeObserver = new ResizeObserver((entries) => {
const elem = entries[0].target as HTMLTextAreaElement;
if (elem.offsetWidth !== width && width) {
resize(elem);
}
width = elem.offsetWidth;
});
resizeObserver.observe(el);
dom.onDisposeElem(el, () => resizeObserver.disconnect());
el.addEventListener('input', () => resize(el));
dom.autoDisposeElem(el, text.addListener(() => resize(el)));
dom.autoDisposeElem(el, text.addListener(() => setImmediate(() => resize(el))));
setTimeout(() => resize(el), 10);
dom.autoDisposeElem(el, text.addListener(val => {
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)

View File

@@ -1,3 +1,4 @@
import {autoGrow} from 'app/client/ui/forms';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {dom, DomElementArg, IDomArgs, IInputOptions, Observable, styled, subscribe} from 'grainjs';
@@ -47,24 +48,50 @@ export function textInput(obs: Observable<string|undefined>, ...args: DomElement
);
}
export interface ITextAreaOptions extends IInputOptions {
autoGrow?: boolean;
save?: (value: string) => void;
}
export function textarea(
obs: Observable<string>, options: IInputOptions, ...args: IDomArgs<HTMLTextAreaElement>
obs: Observable<string>, options?: ITextAreaOptions|null, ...args: IDomArgs<HTMLTextAreaElement>
): HTMLTextAreaElement {
const isValid = options.isValid;
const isValid = options?.isValid;
function setValue(elem: HTMLTextAreaElement) {
obs.set(elem.value);
if (options?.save) { options.save(elem.value); }
else { obs.set(elem.value); }
if (isValid) { isValid.set(elem.validity.valid); }
}
const value = options?.autoGrow ? Observable.create(null, obs.get()) : null;
const trackInput = Boolean(options?.onInput || options?.autoGrow);
const onInput = trackInput ? dom.on('input', (e, elem: HTMLTextAreaElement) => {
if (options?.onInput) {
setValue(elem);
}
if (options?.autoGrow) {
value?.set(elem.value);
}
}) : null;
return dom('textarea', ...args,
dom.prop('value', obs),
value ? [
dom.autoDispose(value),
dom.autoDispose(obs.addListener(v => value.set(v))),
] : null,
dom.prop('value', use => use(obs) ?? ''),
(isValid ?
(elem) => dom.autoDisposeElem(elem,
subscribe(obs, (use) => isValid.set(elem.checkValidity()))) :
null),
options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null,
onInput,
options?.autoGrow ? [
autoGrow(value!),
dom.style('resize', 'none')
] : null,
dom.on('change', (e, elem) => setValue(elem)),
);
}