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