mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Formula UI redesign
Summary: Redesigning column type section to make it more user-friendly. Introducing column behavior concept. Column can be either: - Empty Formula Column: initial state (user can convert to Formula/Data Column) - Data Column: non formula column with or without trigger (with option to add trigger, or convert to formula) - Formula Column: pure formula column, with an option to convert to data column with a trigger. Test Plan: Existing tests. Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D3092
This commit is contained in:
@@ -625,6 +625,30 @@ export class GristDoc extends DisposableWithEvents {
|
||||
);
|
||||
}
|
||||
|
||||
// Convert column to pure formula column.
|
||||
public async convertToFormula(colRefs: number, formula: string): Promise<void> {
|
||||
return this.docModel.columns.sendTableAction(
|
||||
['UpdateRecord', colRefs, {
|
||||
isFormula: true,
|
||||
formula,
|
||||
recalcWhen: RecalcWhen.DEFAULT,
|
||||
recalcDeps: null,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
// Convert column to data column with a trigger formula
|
||||
public async convertToTrigger(colRefs: number, formula: string): Promise<void> {
|
||||
return this.docModel.columns.sendTableAction(
|
||||
['UpdateRecord', colRefs, {
|
||||
isFormula: false,
|
||||
formula,
|
||||
recalcWhen: RecalcWhen.DEFAULT,
|
||||
recalcDeps: null,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
public getCsvLink() {
|
||||
const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({
|
||||
colRef : field.colRef.peek(),
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import type {GristDoc} from 'app/client/components/GristDoc';
|
||||
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import type {CursorPos} from "app/client/components/Cursor";
|
||||
import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||
import {cssEmptySeparator, cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
||||
import {menu, menuItem} from 'app/client/ui2018/menus';
|
||||
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
|
||||
import {sanitizeIdent} from 'app/common/gutil';
|
||||
import {Computed, dom, fromKo, MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||
import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder, Observable,
|
||||
styled, subscribe} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import debounce = require('lodash/debounce');
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
|
||||
export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, cursor: ko.Computed<CursorPos>) {
|
||||
const untieColId = origColumn.untieColIdFromLabel;
|
||||
@@ -63,86 +66,237 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
|
||||
];
|
||||
}
|
||||
|
||||
type BuildEditor = (cellElem: Element) => void;
|
||||
type BuildEditor = (
|
||||
cellElem: Element,
|
||||
editValue?: string,
|
||||
onSave?: (formula: string) => Promise<void>,
|
||||
onCancel?: () => void) => void;
|
||||
|
||||
type BEHAVIOR = "empty"|"formula"|"data";
|
||||
|
||||
export function buildFormulaConfig(
|
||||
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
|
||||
) {
|
||||
const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]);
|
||||
const convertIsFormula =
|
||||
(opts: {toFormula: boolean, noRecalc?: boolean}) => gristDoc.convertIsFormula([origColumn.id.peek()], opts);
|
||||
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
|
||||
|
||||
return dom.maybe(use => {
|
||||
if (!use(origColumn.id)) { return null; } // Invalid column, show nothing.
|
||||
if (use(origColumn.isEmpty)) { return "empty"; }
|
||||
return use(origColumn.isFormula) ? "formula" : "data";
|
||||
},
|
||||
(type: "empty"|"formula"|"data") => {
|
||||
function buildHeader(label: string, menuFunc: () => Element[]) {
|
||||
return cssRow(
|
||||
cssInlineLabel(label,
|
||||
testId('field-is-formula-label'),
|
||||
),
|
||||
cssDropdownLabel('Actions', icon('Dropdown'), menu(menuFunc),
|
||||
cssDropdownLabel.cls('-disabled', origColumn.disableModify),
|
||||
testId('field-actions-menu'),
|
||||
)
|
||||
);
|
||||
}
|
||||
function buildFormulaRow(placeholder = 'Enter formula') {
|
||||
return [
|
||||
cssRow(dom.create(buildFormula, origColumn, buildEditor, placeholder)),
|
||||
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
|
||||
];
|
||||
}
|
||||
if (type === "empty") {
|
||||
return [
|
||||
buildHeader('EMPTY COLUMN', () => [
|
||||
menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)),
|
||||
menuItem(() => convertIsFormula({toFormula: false}), 'Make into data column'),
|
||||
]),
|
||||
buildFormulaRow(),
|
||||
];
|
||||
} else if (type === "formula") {
|
||||
return [
|
||||
buildHeader('FORMULA COLUMN', () => [
|
||||
menuItem(clearColumn, 'Clear column'),
|
||||
menuItem(() => convertIsFormula({toFormula: false, noRecalc: true}), 'Convert to data column'),
|
||||
]),
|
||||
buildFormulaRow(),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
buildHeader('DATA COLUMN', () => {
|
||||
return origColumn.formula.peek() ? [
|
||||
// If there is a formula available, offer a separate option to convert to formula
|
||||
// without clearing it.
|
||||
menuItem(clearColumn, 'Clear column'),
|
||||
menuItem(() => convertIsFormula({toFormula: true}), 'Convert to formula column'),
|
||||
] : [
|
||||
menuItem(clearColumn, 'Clear and make into formula'),
|
||||
];
|
||||
}),
|
||||
buildFormulaRow('Optional formula'),
|
||||
dom.domComputed(use => Boolean(use(origColumn.formula)), (haveFormula) => haveFormula ?
|
||||
dom.create(buildFormulaTriggers, origColumn) :
|
||||
cssHintRow('For default values, automatic updates, and data-cleaning.')
|
||||
)
|
||||
];
|
||||
}
|
||||
// Intermediate state - user wants to specify formula, but haven't done yet
|
||||
const maybeFormula = Observable.create(owner, false);
|
||||
|
||||
// Intermediate state - user wants to specify formula, but haven't done yet
|
||||
const maybeTrigger = Observable.create(owner, false);
|
||||
|
||||
// Column behaviour. There are 3 types of behaviors:
|
||||
// - empty: isFormula and formula == ''
|
||||
// - formula: isFormula and formula != ''
|
||||
// - data: not isFormula nd formula == ''
|
||||
const behavior = Computed.create<BEHAVIOR|null>(owner, (use) => {
|
||||
// When no id column is invalid, show nothing.
|
||||
if (!use(origColumn.id)) { return null; }
|
||||
// Column is a formula column, when it is a formula column with valid formula or will be a formula.
|
||||
if (use(origColumn.isRealFormula) || use(maybeFormula)) { return "formula"; }
|
||||
// If column is not empty, or empty but wants to be a trigger
|
||||
if (use(maybeTrigger) || !use(origColumn.isEmpty)) { return "data"; }
|
||||
return "empty";
|
||||
});
|
||||
|
||||
// Reference to current editor, we will open it when user wants to specify a formula or trigger.
|
||||
// And close it dispose it when user opens up behavior menu.
|
||||
let formulaField: HTMLElement|null = null;
|
||||
|
||||
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
|
||||
const clearState = () => bundleChanges(() => {
|
||||
maybeFormula.set(false);
|
||||
maybeTrigger.set(false);
|
||||
formulaField = null;
|
||||
});
|
||||
|
||||
// Clear state when column has changed
|
||||
owner.autoDispose(origColumn.id.subscribe(clearState));
|
||||
|
||||
// Menu helper that will show normal menu with some default options
|
||||
const menu = (label: DomContents, options: DomElementArg[]) =>
|
||||
cssRow(
|
||||
selectMenu(
|
||||
label,
|
||||
() => options,
|
||||
testId("field-behaviour"),
|
||||
// HACK: Menu helper will add tabindex to this element, which will make
|
||||
// this element focusable and will steal focus from clipboard. This in turn,
|
||||
// will not dispose the formula editor when menu is clicked.
|
||||
(el) => el.removeAttribute("tabindex"),
|
||||
dom.cls("disabled", origColumn.disableModify)),
|
||||
);
|
||||
|
||||
// Behaviour label
|
||||
const behaviorName = Computed.create(owner, behavior, (use, type) => {
|
||||
if (type === 'formula') { return "Formula Column"; }
|
||||
if (type === 'data') { return "Data Column"; }
|
||||
return "Empty Column";
|
||||
});
|
||||
const behaviorIcon = Computed.create<IconName>(owner, (use) => {
|
||||
return use(behaviorName) === "Data Column" ? "Database" : "Script";
|
||||
});
|
||||
const behaviourLabel = () => selectTitle(behaviorName, behaviorIcon);
|
||||
|
||||
// Actions on select menu:
|
||||
|
||||
// Converts data column to formula column.
|
||||
const convertDataColumnToFormulaOption = () => selectOption(
|
||||
() => (maybeFormula.set(true), formulaField?.focus()),
|
||||
'Clear and make into formula', 'Script');
|
||||
|
||||
// Converts to empty column and opens up the editor. (label is the same, but this is used when we have no formula)
|
||||
const convertTriggerToFormulaOption = () => selectOption(
|
||||
() => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: true, noRecalc: true}),
|
||||
'Clear and make into formula', 'Script');
|
||||
|
||||
// Convert column to data.
|
||||
// This method is also available through a text button.
|
||||
const convertToData = () => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: true});
|
||||
const convertToDataOption = () => selectOption(
|
||||
convertToData,
|
||||
'Convert column to data', 'Database');
|
||||
|
||||
// Clears the column
|
||||
const clearAndResetOption = () => selectOption(
|
||||
() => gristDoc.clearColumns([origColumn.id.peek()]),
|
||||
'Clear and reset', 'CrossSmall');
|
||||
|
||||
// Actions on text buttons:
|
||||
|
||||
// Tries to convert data column to a trigger column.
|
||||
const convertDataColumnToTriggerColumn = () => {
|
||||
maybeTrigger.set(true);
|
||||
// Open the formula editor.
|
||||
formulaField?.focus();
|
||||
};
|
||||
|
||||
// Converts formula column to trigger formula column.
|
||||
const convertFormulaToTrigger = () =>
|
||||
gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: false});
|
||||
|
||||
const setFormula = () => (maybeFormula.set(true), formulaField?.focus());
|
||||
const setTrigger = () => (maybeTrigger.set(true), formulaField?.focus());
|
||||
|
||||
// Actions on save formula
|
||||
|
||||
// Converts column to formula column or updates formula on a formula column.
|
||||
const onSaveConvertToFormula = async (formula: string) => {
|
||||
const notBlank = Boolean(formula);
|
||||
const trueFormula = !maybeFormula.get();
|
||||
if (notBlank || trueFormula) { await gristDoc.convertToFormula(origColumn.id.peek(), formula); }
|
||||
clearState();
|
||||
};
|
||||
|
||||
// Updates formula or convert column to trigger formula column if necessary.
|
||||
const onSaveConvertToTrigger = async (formula: string) => {
|
||||
if (formula && maybeTrigger.get()) {
|
||||
// Convert column to trigger
|
||||
await gristDoc.convertToTrigger(origColumn.id.peek(), formula);
|
||||
} else if (origColumn.hasTriggerFormula.peek()) {
|
||||
// This is true trigger formula, just update the formula (or make it blank)
|
||||
await origColumn.formula.setAndSave(formula);
|
||||
}
|
||||
);
|
||||
clearState();
|
||||
};
|
||||
|
||||
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
|
||||
// Helper that will create different flavors for formula builder.
|
||||
const formulaBuilder = (onSave: (formula: string) => Promise<void>) => [
|
||||
cssRow(formulaField = buildFormula(
|
||||
origColumn,
|
||||
buildEditor,
|
||||
"Enter formula",
|
||||
onSave,
|
||||
clearState)),
|
||||
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
|
||||
];
|
||||
|
||||
return dom.maybe(behavior, (type: BEHAVIOR) => [
|
||||
cssLabel('COLUMN BEHAVIOR'),
|
||||
...(type === "empty" ? [
|
||||
menu(behaviourLabel(), [
|
||||
convertToDataOption()
|
||||
]),
|
||||
cssEmptySeparator(),
|
||||
cssRow(textButton(
|
||||
"Set formula",
|
||||
dom.on("click", setFormula),
|
||||
dom.prop("disabled", origColumn.disableModify),
|
||||
testId("field-set-formula")
|
||||
)),
|
||||
cssRow(textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", setTrigger),
|
||||
dom.prop("disabled", origColumn.disableModify),
|
||||
testId("field-set-trigger")
|
||||
)),
|
||||
cssRow(textButton(
|
||||
"Make into data column",
|
||||
dom.on("click", convertToData),
|
||||
dom.prop("disabled", origColumn.disableModify),
|
||||
testId("field-set-data")
|
||||
))
|
||||
] : type === "formula" ? [
|
||||
menu(behaviourLabel(), [
|
||||
convertToDataOption(),
|
||||
clearAndResetOption(),
|
||||
]),
|
||||
formulaBuilder(onSaveConvertToFormula),
|
||||
cssEmptySeparator(),
|
||||
cssRow(textButton(
|
||||
"Convert to trigger formula",
|
||||
dom.on("click", convertFormulaToTrigger),
|
||||
dom.hide(maybeFormula),
|
||||
dom.prop("disabled", origColumn.disableModify),
|
||||
testId("field-set-trigger")
|
||||
))
|
||||
] : /* type == 'data' */ [
|
||||
menu(behaviourLabel(),
|
||||
[
|
||||
dom.domComputed(origColumn.hasTriggerFormula, (hasTrigger) => hasTrigger ?
|
||||
// If we have trigger, we will convert it directly to a formula column
|
||||
convertTriggerToFormulaOption() :
|
||||
// else we will convert to empty column and open up the editor
|
||||
convertDataColumnToFormulaOption()
|
||||
),
|
||||
clearAndResetOption(),
|
||||
]
|
||||
),
|
||||
// If data column is or wants to be a trigger formula:
|
||||
dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [
|
||||
cssLabel('TRIGGER FORMULA'),
|
||||
formulaBuilder(onSaveConvertToTrigger),
|
||||
dom.create(buildFormulaTriggers, origColumn, maybeTrigger)
|
||||
]),
|
||||
// Else offer a way to convert to trigger formula.
|
||||
dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [
|
||||
cssEmptySeparator(),
|
||||
cssRow(textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", convertDataColumnToTriggerColumn),
|
||||
dom.prop("disabled", origColumn.disableModify),
|
||||
testId("field-set-trigger")
|
||||
))
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function buildFormula(owner: MultiHolder, column: ColumnRec, buildEditor: BuildEditor, placeholder: string) {
|
||||
function buildFormula(
|
||||
column: ColumnRec,
|
||||
buildEditor: BuildEditor,
|
||||
placeholder: string,
|
||||
onSave?: (formula: string) => Promise<void>,
|
||||
onCancel?: () => void) {
|
||||
return cssFieldFormula(column.formula, {placeholder, maxLines: 2},
|
||||
dom.cls('formula_field_sidepane'),
|
||||
cssFieldFormula.cls('-disabled', column.disableModify),
|
||||
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
|
||||
dom.cls('disabled'),
|
||||
{tabIndex: '-1'},
|
||||
dom.on('focus', (ev, elem) => buildEditor(elem)),
|
||||
// Focus event use used by a user to edit an existing formula.
|
||||
// It can also be triggered manually to open up the editor.
|
||||
dom.on('focus', (_, elem) => buildEditor(elem, undefined, onSave, onCancel)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,36 +377,6 @@ const cssToggleButton = styled(cssIconButton, `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssInlineLabel = styled(cssLabel, `
|
||||
padding: 4px 8px;
|
||||
margin: 4px 0 -4px -8px;
|
||||
`);
|
||||
|
||||
const cssDropdownLabel = styled(cssInlineLabel, `
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: ${vars.controlBorderRadius};
|
||||
cursor: pointer;
|
||||
|
||||
color: ${colors.lightGreen};
|
||||
--icon-color: ${colors.lightGreen};
|
||||
|
||||
&:hover, &:focus, &.weasel-popup-open {
|
||||
background-color: ${colors.mediumGrey};
|
||||
}
|
||||
&-disabled {
|
||||
color: ${colors.slate};
|
||||
--icon-color: ${colors.slate};
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHintRow = styled('div', `
|
||||
margin: -4px 16px 8px 16px;
|
||||
color: ${colors.slate};
|
||||
`);
|
||||
|
||||
const cssColLabelBlock = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -230,11 +230,19 @@ export class RightPanel extends Disposable {
|
||||
}
|
||||
|
||||
// Helper to activate the side-pane formula editor over the given HTML element.
|
||||
private _activateFormulaEditor(refElem: Element) {
|
||||
private _activateFormulaEditor(
|
||||
// Element to attach to.
|
||||
refElem: Element,
|
||||
// Simulate user typing on the cell - open editor with an initial value.
|
||||
editValue?: string,
|
||||
// Custom save handler.
|
||||
onSave?: (formula: string) => Promise<void>,
|
||||
// Custom cancel handler.
|
||||
onCancel?: () => void,) {
|
||||
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
|
||||
if (!vsi) { return; }
|
||||
const editRowModel = vsi.moveEditRowToCursor();
|
||||
vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem);
|
||||
return vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem, editValue, onSave, onCancel);
|
||||
}
|
||||
|
||||
private _buildPageWidgetContent(_owner: MultiHolder) {
|
||||
@@ -657,6 +665,10 @@ export const cssSeparator = styled('div', `
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
export const cssEmptySeparator = styled('div', `
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
const cssConfigContainer = styled('div', `
|
||||
overflow: auto;
|
||||
--color-list-item: none;
|
||||
|
||||
@@ -20,7 +20,7 @@ import isEqual = require('lodash/isEqual');
|
||||
/**
|
||||
* Build UI to select triggers for formulas in data columns (such for default values).
|
||||
*/
|
||||
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) {
|
||||
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disable: Observable<boolean>|null = null) {
|
||||
// Set up observables to translate between the UI representation of triggers, and what we
|
||||
// actually store.
|
||||
// - We store the pair (recalcWhen, recalcDeps). When recalcWhen is DEFAULT, recalcDeps lists
|
||||
@@ -79,7 +79,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) {
|
||||
labeledSquareCheckbox(
|
||||
applyToNew,
|
||||
'Apply to new records',
|
||||
dom.boolAttr('disabled', applyOnChanges),
|
||||
dom.boolAttr('disabled', (use) => (disable && use(disable)) || use(applyOnChanges)),
|
||||
testId('field-formula-apply-to-new'),
|
||||
),
|
||||
),
|
||||
@@ -90,6 +90,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) {
|
||||
'Apply on changes to:' :
|
||||
'Apply on record changes'
|
||||
),
|
||||
dom.boolAttr('disabled', (use) => disable ? use(disable) : false),
|
||||
testId('field-formula-apply-on-changes'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -38,6 +38,7 @@ export type IconName = "ChartArea" |
|
||||
"Copy" |
|
||||
"CrossBig" |
|
||||
"CrossSmall" |
|
||||
"Database" |
|
||||
"Dots" |
|
||||
"Download" |
|
||||
"DragDrop" |
|
||||
@@ -79,6 +80,7 @@ export type IconName = "ChartArea" |
|
||||
"Repl" |
|
||||
"ResizePanel" |
|
||||
"RightAlign" |
|
||||
"Script" |
|
||||
"Search" |
|
||||
"Settings" |
|
||||
"Share" |
|
||||
@@ -133,6 +135,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Copy",
|
||||
"CrossBig",
|
||||
"CrossSmall",
|
||||
"Database",
|
||||
"Dots",
|
||||
"Download",
|
||||
"DragDrop",
|
||||
@@ -174,6 +177,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Repl",
|
||||
"ResizePanel",
|
||||
"RightAlign",
|
||||
"Script",
|
||||
"Search",
|
||||
"Settings",
|
||||
"Share",
|
||||
|
||||
@@ -100,6 +100,16 @@ export const bigBasicButtonLink = tbind(button, null, {link: true, large: true})
|
||||
export const primaryButtonLink = tbind(button, null, {link: true, primary: true});
|
||||
export const bigPrimaryButtonLink = tbind(button, null, {link: true, large: true, primary: true});
|
||||
|
||||
// Button that looks like a link (have no background and no border).
|
||||
export const textButton = styled(cssButton, `
|
||||
border: none;
|
||||
padding: 0px;
|
||||
background-color: inherit !important;
|
||||
&:disabled {
|
||||
color: ${colors.inactiveCursor};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssButtonLink = styled('a', `
|
||||
display: inline-block;
|
||||
&, &:hover, &:focus {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {Command} from 'app/client/components/commands';
|
||||
import {NeedUpgradeError, reportError} from 'app/client/models/errors';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {cssSelectBtn} from 'app/client/ui2018/select';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {Computed, dom, DomElementArg, DomElementMethod, MaybeObsArray, MutableObsArray, Observable,
|
||||
styled} from 'grainjs';
|
||||
import { Command } from 'app/client/components/commands';
|
||||
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
|
||||
import { cssCheckboxSquare, cssLabel, cssLabelText } from 'app/client/ui2018/checkbox';
|
||||
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
|
||||
import { IconName } from 'app/client/ui2018/IconList';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { cssSelectBtn } from 'app/client/ui2018/select';
|
||||
import { commonUrls } from 'app/common/gristUrls';
|
||||
import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
|
||||
MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs';
|
||||
import * as weasel from 'popweasel';
|
||||
import {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox';
|
||||
|
||||
export interface IOptionFull<T> {
|
||||
value: T;
|
||||
@@ -304,6 +304,71 @@ export function autocomplete(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates simple (not reactive) static menu that looks like a select-box.
|
||||
* Primary usage is for menus, where you want to control how the options and a default
|
||||
* label will look. Label is not updated or changed when one of the option is clicked, for those
|
||||
* use cases use a select component.
|
||||
* Icons are optional, can use custom elements instead of labels and options.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* selectMenu(selectTitle("Title", "Script"), () => [
|
||||
* selectOption(() => ..., "Option1", "Database"),
|
||||
* selectOption(() => ..., "Option2", "Script"),
|
||||
* ]);
|
||||
*
|
||||
* // Control disabled state (if the menu will be opened or not)
|
||||
*
|
||||
* const disabled = observable(false);
|
||||
* selectMenu(selectTitle("Title", "Script"), () => [
|
||||
* selectOption(() => ..., "Option1", "Database"),
|
||||
* selectOption(() => ..., "Option2", "Script"),
|
||||
* ], disabled);
|
||||
*
|
||||
*/
|
||||
export function selectMenu(
|
||||
label: DomElementArg,
|
||||
items: () => DomElementArg[],
|
||||
...args: IDomArgs<HTMLDivElement>
|
||||
) {
|
||||
return cssSelectBtn(
|
||||
label,
|
||||
icon('Dropdown'),
|
||||
menu(
|
||||
items,
|
||||
{
|
||||
...weasel.defaultMenuOptions,
|
||||
menuCssClass: cssSelectMenuElem.className + ' grist-floating-menu',
|
||||
stretchToSelector : `.${cssSelectBtn.className}`,
|
||||
trigger : [(triggerElem, ctl) => {
|
||||
const isDisabled = () => triggerElem.classList.contains('disabled');
|
||||
dom.onElem(triggerElem, 'click', () => isDisabled() || ctl.toggle());
|
||||
dom.onKeyElem(triggerElem as HTMLElement, 'keydown', {
|
||||
ArrowDown: () => isDisabled() || ctl.open(),
|
||||
ArrowUp: () => isDisabled() || ctl.open()
|
||||
});
|
||||
}]
|
||||
},
|
||||
),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
export function selectTitle(label: BindableValue<string>, iconName?: BindableValue<IconName>) {
|
||||
return cssOptionRow(
|
||||
iconName ? dom.domComputed(iconName, (name) => cssOptionRowIcon(name)) : null,
|
||||
dom.text(label)
|
||||
);
|
||||
}
|
||||
|
||||
export function selectOption(
|
||||
action: (item: HTMLElement) => void,
|
||||
label: BindableValue<string>,
|
||||
iconName?: BindableValue<IconName>) {
|
||||
return menuItem(action, selectTitle(label, iconName));
|
||||
}
|
||||
|
||||
export const menuSubHeader = styled('div', `
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
text-transform: uppercase;
|
||||
@@ -404,13 +469,13 @@ const cssOptionIcon = styled(icon, `
|
||||
margin: -3px 8px 0 2px;
|
||||
`);
|
||||
|
||||
const cssOptionRow = styled('span', `
|
||||
export const cssOptionRow = styled('span', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssOptionRowIcon = styled(cssOptionIcon, `
|
||||
export const cssOptionRowIcon = styled(cssOptionIcon, `
|
||||
margin: 0 8px 0 0;
|
||||
flex: none;
|
||||
|
||||
|
||||
@@ -517,13 +517,22 @@ export class FieldBuilder extends Disposable {
|
||||
/**
|
||||
* Open the formula editor in the side pane. It will be positioned over refElem.
|
||||
*/
|
||||
public openSideFormulaEditor(editRow: DataRowModel, refElem: Element) {
|
||||
public openSideFormulaEditor(
|
||||
editRow: DataRowModel,
|
||||
refElem: Element,
|
||||
editValue?: string,
|
||||
onSave?: (formula: string) => Promise<void>,
|
||||
onCancel?: () => void) {
|
||||
const editorHolder = openSideFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
editRow,
|
||||
refElem,
|
||||
editValue,
|
||||
onSave,
|
||||
onCancel
|
||||
});
|
||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {asyncOnce} from "app/common/AsyncCreate";
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {isRaisedException} from 'app/common/gristTypes';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {Disposable, Emitter, Holder, IDisposable, MultiHolder, Observable} from 'grainjs';
|
||||
import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import { CellPosition } from "app/client/components/CellPosition";
|
||||
|
||||
@@ -380,7 +380,10 @@ export function openSideFormulaEditor(options: {
|
||||
field: ViewFieldRec,
|
||||
editRow: DataRowModel, // Needed to get exception value, if any.
|
||||
refElem: Element, // Element in the side pane over which to position the editor.
|
||||
}): IDisposable {
|
||||
editValue?: string,
|
||||
onSave?: (formula: string) => Promise<void>,
|
||||
onCancel?: () => void,
|
||||
}): Disposable {
|
||||
const {gristDoc, field, editRow, refElem} = options;
|
||||
const holder = MultiHolder.create(null);
|
||||
const column = field.column();
|
||||
@@ -388,17 +391,19 @@ export function openSideFormulaEditor(options: {
|
||||
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||
const saveEdit = asyncOnce(async () => {
|
||||
const formula = editor.getCellValue();
|
||||
if (formula !== column.formula.peek()) {
|
||||
if (options.onSave) {
|
||||
await options.onSave(formula as string);
|
||||
} else if (formula !== column.formula.peek()) {
|
||||
await column.updateColValues({formula});
|
||||
}
|
||||
holder.dispose(); // Deactivate the editor.
|
||||
holder.dispose();
|
||||
});
|
||||
|
||||
// These are the commands for while the editor is active.
|
||||
const editCommands = {
|
||||
fieldEditSave: () => { saveEdit().catch(reportError); },
|
||||
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
||||
fieldEditCancel: () => { holder.dispose(); },
|
||||
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
|
||||
};
|
||||
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
@@ -407,7 +412,7 @@ export function openSideFormulaEditor(options: {
|
||||
field,
|
||||
cellValue: column.formula(),
|
||||
formulaError: getFormulaError(gristDoc, editRow, column),
|
||||
editValue: undefined,
|
||||
editValue: options.editValue,
|
||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||
commands: editCommands,
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
|
||||
Reference in New Issue
Block a user