(core) Add dropdown conditions

Summary:
Dropdown conditions let you specify a predicate formula that's used to filter
choices and references in their respective autocomplete dropdown menus.

Test Plan: Python and browser tests (WIP).

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D4235
This commit is contained in:
George Gevoian
2024-04-26 16:34:16 -04:00
parent 34c85757f1
commit 3112433a58
86 changed files with 4221 additions and 1060 deletions

View File

@@ -7,10 +7,10 @@ require('ace-builds/src-noconflict/theme-chrome');
require('ace-builds/src-noconflict/theme-dracula');
require('ace-builds/src-noconflict/ext-language_tools');
var {setupAceEditorCompletions} = require('./AceEditorCompletions');
var {getGristConfig} = require('../../common/urlUtils');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var modelUtil = require('../models/modelUtil');
var {gristThemeObs} = require('../ui2018/theme');
/**
* A class to help set up the ace editor with standard formatting and convenience functions
@@ -28,10 +28,9 @@ function AceEditor(options) {
this.observable = options.observable || null;
this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false);
this.calcSize = options.calcSize || ((_elem, size) => size);
this.gristDoc = options.gristDoc || null;
this.column = options.column || null;
this.editorState = options.editorState || null;
this._readonly = options.readonly || false;
this._getSuggestions = options.getSuggestions || null;
this.editor = null;
this.editorDom = null;
@@ -185,19 +184,8 @@ AceEditor.prototype.setFontSize = function(pxVal) {
AceEditor.prototype._setup = function() {
// Standard editor setup
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
if (this.gristDoc && this.column) {
const getSuggestions = (prefix) => {
const section = this.gristDoc.viewModel.activeSection();
// If section is disposed or is pointing to an empty row, don't try to autocomplete.
if (!section?.getRowId()) {
return [];
}
const tableId = section.table().tableId();
const columnId = this.column.colId();
const rowId = section.activeRowId();
return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId);
};
setupAceEditorCompletions(this.editor, {getSuggestions});
if (this._getSuggestions) {
setupAceEditorCompletions(this.editor, {getSuggestions: this._getSuggestions});
}
this.editor.setOptions({
enableLiveAutocompletion: true, // use autocompletion without needing special activation.
@@ -205,13 +193,10 @@ AceEditor.prototype._setup = function() {
this.session = this.editor.getSession();
this.session.setMode('ace/mode/python');
const gristTheme = this.gristDoc?.currentTheme;
this._setAceTheme(gristTheme?.get());
if (!getGristConfig().enableCustomCss && gristTheme) {
this.autoDispose(gristTheme.addListener((theme) => {
this._setAceTheme(theme);
}));
}
this._setAceTheme(gristThemeObs().get());
this.autoDispose(gristThemeObs().addListener((newTheme) => {
this._setAceTheme(newTheme);
}));
// Default line numbers to hidden
this.editor.renderer.setShowGutter(false);
@@ -283,10 +268,9 @@ AceEditor.prototype._getContentHeight = function() {
return Math.max(1, this.session.getScreenLength()) * this.editor.renderer.lineHeight;
};
AceEditor.prototype._setAceTheme = function(gristTheme) {
const {enableCustomCss} = getGristConfig();
const gristAppearance = gristTheme?.appearance;
const aceTheme = gristAppearance === 'dark' && !enableCustomCss ? 'dracula' : 'chrome';
AceEditor.prototype._setAceTheme = function(newTheme) {
const {appearance} = newTheme;
const aceTheme = appearance === 'dark' ? 'dracula' : 'chrome';
this.editor.setTheme(`ace/theme/${aceTheme}`);
};

View File

@@ -17,6 +17,7 @@ import {cssFieldEntry, cssFieldLabel, IField, VisibleFieldsConfig } from 'app/cl
import {IconName} from 'app/client/ui2018/IconList';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, linkSelect, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
@@ -229,7 +230,7 @@ export class ChartView extends Disposable {
this.listenTo(this.sortedRows, 'rowNotify', this._update);
this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update));
this.autoDispose(this._formatterComp.subscribe(this._update));
this.autoDispose(this.gristDoc.currentTheme.addListener(() => this._update()));
this.autoDispose(gristThemeObs().addListener(() => this._update()));
}
public prepareToPrint(onOff: boolean) {
@@ -387,8 +388,7 @@ export class ChartView extends Disposable {
}
private _getPlotlyTheme(): Partial<Layout> {
const appModel = this.gristDoc.docPageModel.appModel;
const {colors} = appModel.currentTheme.get();
const {colors} = gristThemeObs().get();
return {
paper_bgcolor: colors['chart-bg'],
plot_bgcolor: colors['chart-bg'],

View File

@@ -91,9 +91,9 @@ export class ColumnTransform extends Disposable {
protected buildEditorDom(optInit?: string) {
if (!this.editor) {
this.editor = this.autoDispose(AceEditor.create({
gristDoc: this.gristDoc,
observable: this.transformColumn.formula,
saveValueOnBlurEvent: false,
// TODO: set `getSuggestions` (see `FormulaEditor.ts` for an example).
}));
}
return this.editor.buildDom((aceObj: any) => {

View File

@@ -321,7 +321,7 @@ export class CustomView extends Disposable {
}),
new MinimumLevel(AccessLevel.none)); // none access is enough
frame.useEvents(
ThemeNotifier.create(frame, this.gristDoc.currentTheme),
ThemeNotifier.create(frame),
new MinimumLevel(AccessLevel.none));
},
onElem: (iframe) => onFrameFocus(iframe, () => {

View File

@@ -0,0 +1,197 @@
import {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {textButton } from 'app/client/ui2018/buttons';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {getPredicateFormulaProperties} from 'app/common/PredicateFormula';
import {Computed, Disposable, dom, Observable, styled} from 'grainjs';
const t = makeT('DropdownConditionConfig');
/**
* Right panel configuration for dropdown conditions.
*
* Contains an instance of `DropdownConditionEditor`, the class responsible
* for setting dropdown conditions.
*/
export class DropdownConditionConfig extends Disposable {
private _text = Computed.create(this, use => {
const dropdownCondition = use(this._field.dropdownCondition);
if (!dropdownCondition) { return ''; }
return dropdownCondition.text;
});
private _saveError = Observable.create<string | null>(this, null);
private _properties = Computed.create(this, use => {
const dropdownCondition = use(this._field.dropdownCondition);
if (!dropdownCondition?.parsed) { return null; }
return getPredicateFormulaProperties(JSON.parse(dropdownCondition.parsed));
});
private _column = Computed.create(this, use => use(this._field.column));
private _columns = Computed.create(this, use => use(use(use(this._column).table).visibleColumns));
private _refColumns = Computed.create(this, use => {
const refTable = use(use(this._column).refTable);
if (!refTable) { return null; }
return use(refTable.visibleColumns);
});
private _propertiesError = Computed.create<string | null>(this, use => {
const properties = use(this._properties);
if (!properties) { return null; }
const {recColIds = [], choiceColIds = []} = properties;
const columns = use(this._columns);
const validRecColIds = new Set(columns.map((({colId}) => use(colId))));
const invalidRecColIds = recColIds.filter(colId => !validRecColIds.has(colId));
if (invalidRecColIds.length > 0) {
return t('Invalid columns: {{colIds}}', {colIds: invalidRecColIds.join(', ')});
}
const refColumns = use(this._refColumns);
if (refColumns) {
const validChoiceColIds = new Set(['id', ...refColumns.map((({colId}) => use(colId)))]);
const invalidChoiceColIds = choiceColIds.filter(colId => !validChoiceColIds.has(colId));
if (invalidChoiceColIds.length > 0) {
return t('Invalid columns: {{colIds}}', {colIds: invalidChoiceColIds.join(', ')});
}
}
return null;
});
private _error = Computed.create<string | null>(this, (use) => {
const maybeSaveError = use(this._saveError);
if (maybeSaveError) { return maybeSaveError; }
const maybeCompiled = use(this._field.dropdownConditionCompiled);
if (maybeCompiled?.kind === 'failure') { return maybeCompiled.error; }
const maybePropertiesError = use(this._propertiesError);
if (maybePropertiesError) { return maybePropertiesError; }
return null;
});
private _disabled = Computed.create(this, use =>
use(this._field.disableModify) ||
use(use(this._column).disableEditData) ||
use(this._field.config.multiselect)
);
private _isEditingCondition = Observable.create(this, false);
private _isRefField = Computed.create(this, (use) =>
['Ref', 'RefList'].includes(use(use(this._column).pureType)));
private _tooltip = Computed.create(this, use => use(this._isRefField)
? 'setRefDropdownCondition'
: 'setChoiceDropdownCondition');
private _editorElement: HTMLElement;
constructor(private _field: ViewFieldRec) {
super();
this.autoDispose(this._text.addListener(() => {
this._saveError.set('');
}));
}
public buildDom() {
return [
dom.maybe((use) => !(use(this._isEditingCondition) || Boolean(use(this._text))), () => [
cssSetDropdownConditionRow(
dom.domComputed(use => withInfoTooltip(
textButton(
t('Set dropdown condition'),
dom.on('click', () => {
this._isEditingCondition.set(true);
setTimeout(() => this._editorElement.focus(), 0);
}),
dom.prop('disabled', this._disabled),
testId('field-set-dropdown-condition'),
),
use(this._tooltip),
)),
),
]),
dom.maybe((use) => use(this._isEditingCondition) || Boolean(use(this._text)), () => [
cssLabel(t('Dropdown Condition')),
cssRow(
dom.create(buildDropdownConditionEditor,
{
value: this._text,
disabled: this._disabled,
getAutocompleteSuggestions: () => this._getAutocompleteSuggestions(),
onSave: async (value) => {
try {
const widgetOptions = this._field.widgetOptionsJson.peek();
if (value.trim() === '') {
delete widgetOptions.dropdownCondition;
} else {
widgetOptions.dropdownCondition = {text: value};
}
await this._field.widgetOptionsJson.setAndSave(widgetOptions);
} catch (e) {
if (e?.code === 'ACL_DENY') {
reportError(e);
} else {
this._saveError.set(e.message.replace(/^\[Sandbox\]/, '').trim());
}
}
},
onDispose: () => {
this._isEditingCondition.set(false);
},
},
(el) => { this._editorElement = el; },
testId('field-dropdown-condition'),
),
),
dom.maybe(this._error, (error) => cssRow(
cssDropdownConditionError(error), testId('field-dropdown-condition-error')),
),
]),
];
}
private _getAutocompleteSuggestions(): ISuggestionWithValue[] {
const variables = ['choice'];
const refColumns = this._refColumns.get();
if (refColumns) {
variables.push('choice.id', ...refColumns.map(({colId}) => `choice.${colId.peek()}`));
}
const columns = this._columns.get();
variables.push(
...columns.map(({colId}) => `$${colId.peek()}`),
...columns.map(({colId}) => `rec.${colId.peek()}`),
);
const suggestions = [
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
'OWNER', 'EDITOR', 'VIEWER',
...variables,
];
return suggestions.map(suggestion => [suggestion, null]);
}
}
const cssSetDropdownConditionRow = styled(cssRow, `
margin-top: 16px;
`);
const cssDropdownConditionError = styled('div', `
color: ${theme.errorText};
margin-top: 4px;
width: 100%;
`);

View File

@@ -0,0 +1,234 @@
import * as AceEditor from 'app/client/components/AceEditor';
import {createGroup} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {theme} from 'app/client/ui2018/cssVars';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {initializeAceOptions} from 'app/client/widgets/FormulaEditor';
import {IEditorCommandGroup} from 'app/client/widgets/NewBaseEditor';
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {
Computed,
Disposable,
dom,
DomElementArg,
Holder,
IDisposableOwner,
Observable,
styled,
} from 'grainjs';
const t = makeT('DropdownConditionEditor');
interface BuildDropdownConditionEditorOptions {
value: Computed<string>;
disabled: Computed<boolean>;
onSave(value: string): Promise<void>;
onDispose(): void;
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
}
/**
* Builds an editor for dropdown conditions.
*
* Dropdown conditions are client-evaluated predicate formulas used to filter
* items shown in autocomplete dropdowns for Choice and Reference type columns.
*
* Unlike Python formulas, dropdown conditions only support a very limited set of
* features. They are a close relative of ACL formulas, sharing the same underlying
* parser and compiler.
*
* See `sandbox/grist/predicate_formula.py` and `app/common/PredicateFormula.ts` for
* more details on parsing and compiling, respectively.
*/
export function buildDropdownConditionEditor(
owner: IDisposableOwner,
options: BuildDropdownConditionEditorOptions,
...args: DomElementArg[]
) {
const {value, disabled, onSave, onDispose, getAutocompleteSuggestions} = options;
return dom.create(buildHighlightedCode,
value,
{maxLines: 1},
dom.cls(cssDropdownConditionField.className),
dom.cls('disabled'),
cssDropdownConditionField.cls('-disabled', disabled),
{tabIndex: '-1'},
dom.on('focus', (_, refElem) => openDropdownConditionEditor(owner, {
refElem,
value,
onSave,
onDispose,
getAutocompleteSuggestions,
})),
...args,
);
}
function openDropdownConditionEditor(owner: IDisposableOwner, options: {
refElem: Element;
value: Computed<string>;
onSave: (value: string) => Promise<void>;
onDispose: () => void;
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
}) {
const {refElem, value, onSave, onDispose, getAutocompleteSuggestions} = options;
const saveAndDispose = async () => {
const editorValue = editor.getValue();
if (editorValue !== value.get()) {
await onSave(editorValue);
}
if (editor.isDisposed()) { return; }
editor.dispose();
};
const commands: IEditorCommandGroup = {
fieldEditCancel: () => editor.dispose(),
fieldEditSaveHere: () => editor.blur(),
fieldEditSave: () => editor.blur(),
};
const editor = DropdownConditionEditor.create(owner, {
editValue: value.get(),
commands,
onBlur: saveAndDispose,
getAutocompleteSuggestions,
});
editor.attach(refElem);
editor.onDispose(() => onDispose());
}
interface DropdownConditionEditorOptions {
editValue: string;
commands: IEditorCommandGroup;
onBlur(): Promise<void>;
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
}
class DropdownConditionEditor extends Disposable {
private _aceEditor: any;
private _dom: HTMLElement;
private _editorPlacement!: EditorPlacement;
private _placementHolder = Holder.create(this);
private _isEmpty: Computed<boolean>;
constructor(private _options: DropdownConditionEditorOptions) {
super();
const initialValue = _options.editValue;
const editorState = Observable.create(this, initialValue);
this._aceEditor = this.autoDispose(AceEditor.create({
calcSize: this._calcSize.bind(this),
editorState,
getSuggestions: _options.getAutocompleteSuggestions,
}));
this._isEmpty = Computed.create(this, editorState, (_use, state) => state === '');
this.autoDispose(this._isEmpty.addListener(() => this._updateEditorPlaceholder()));
const commandGroup = this.autoDispose(createGroup({
..._options.commands,
}, this, true));
this._dom = cssDropdownConditionEditorWrapper(
cssDropdownConditionEditor(
createMobileButtons(_options.commands),
this._aceEditor.buildDom((aceObj: any) => {
initializeAceOptions(aceObj);
const val = initialValue;
const pos = val.length;
this._aceEditor.setValue(val, pos);
this._aceEditor.attachCommandGroup(commandGroup);
if (val === '') {
this._updateEditorPlaceholder();
}
})
),
);
}
public attach(cellElem: Element): void {
this._editorPlacement = EditorPlacement.create(this._placementHolder, this._dom, cellElem, {
margins: getButtonMargins(),
});
this.autoDispose(this._editorPlacement.onReposition.addListener(this._aceEditor.resize, this._aceEditor));
this._aceEditor.onAttach();
this._updateEditorPlaceholder();
this._aceEditor.resize();
this._aceEditor.getEditor().focus();
this._aceEditor.getEditor().on('blur', () => this._options.onBlur());
}
public getValue(): string {
return this._aceEditor.getValue();
}
public blur() {
this._aceEditor.getEditor().blur();
}
private _updateEditorPlaceholder() {
const editor = this._aceEditor.getEditor();
const shouldShowPlaceholder = editor.session.getValue().length === 0;
if (editor.renderer.emptyMessageNode) {
// Remove the current placeholder if one is present.
editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode);
}
if (!shouldShowPlaceholder) {
editor.renderer.emptyMessageNode = null;
} else {
editor.renderer.emptyMessageNode = cssDropdownConditionPlaceholder(t('Enter condition.'));
editor.renderer.scroller.appendChild(editor.renderer.emptyMessageNode);
}
}
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
const placeholder: HTMLElement | undefined = this._aceEditor.getEditor().renderer.emptyMessageNode;
if (placeholder) {
return this._editorPlacement.calcSizeWithPadding(elem, {
width: placeholder.scrollWidth,
height: placeholder.scrollHeight,
});
} else {
return this._editorPlacement.calcSizeWithPadding(elem, {
width: desiredElemSize.width,
height: desiredElemSize.height,
});
}
}
}
const cssDropdownConditionField = styled('div', `
flex: auto;
cursor: pointer;
margin-top: 4px;
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssDropdownConditionEditorWrapper = styled('div.default_editor.formula_editor_wrapper', `
border-radius: 3px;
`);
const cssDropdownConditionEditor = styled('div', `
background-color: ${theme.aceEditorBg};
padding: 5px;
z-index: 10;
overflow: hidden;
flex: none;
min-height: 22px;
border-radius: 3px;
`);
const cssDropdownConditionPlaceholder = styled('div', `
color: ${theme.lightText};
font-style: italic;
white-space: nowrap;
`);

View File

@@ -192,8 +192,6 @@ export class GristDoc extends DisposableWithEvents {
// Holder for the popped up formula editor.
public readonly formulaPopup = Holder.create(this);
public readonly currentTheme = this.docPageModel.appModel.currentTheme;
public get docApi() {
return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!);
}
@@ -238,7 +236,6 @@ export class GristDoc extends DisposableWithEvents {
untrustedContentOrigin: app.topAppModel.getUntrustedContentOrigin(),
docComm: this.docComm,
clientScope: app.clientScope,
theme: this.currentTheme,
});
// Maintain the MetaRowModel for the global document info, including docId and peers.

View File

@@ -1345,8 +1345,9 @@ export class Importer extends DisposableWithEvents {
const column = use(field.column);
return use(column.formula);
});
const codeOptions = {gristTheme: this._gristDoc.currentTheme, placeholder: 'Skip', maxLines: 1};
return cssFieldFormula(formula, codeOptions,
const codeOptions = {placeholder: 'Skip', maxLines: 1};
return dom.create(buildHighlightedCode, formula, codeOptions,
dom.cls(cssFieldFormula.className),
dom.cls('disabled'),
dom.cls('formula_field_sidepane'),
{tabIndex: '-1'},
@@ -1701,7 +1702,7 @@ const cssColumnMatchRow = styled('div', `
}
`);
const cssFieldFormula = styled(buildHighlightedCode, `
const cssFieldFormula = styled('div', `
flex: auto;
cursor: pointer;
margin-top: 1px;

View File

@@ -2,7 +2,7 @@ import {CustomView} from 'app/client/components/CustomView';
import {DataRowModel} from 'app/client/models/DataRowModel';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {prefersDarkMode, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {prefersColorSchemeDark, prefersColorSchemeDarkObs} from 'app/client/ui2018/theme';
import {dom} from 'grainjs';
type RowId = number|'new';
@@ -45,7 +45,7 @@ export async function printViewSection(layout: any, viewSection: ViewSectionRec)
// is Grist temporarily reverting to the light theme until the print dialog is dismissed.
// As a workaround, we'll temporarily pause our listener, and unpause after the print dialog
// is dismissed.
prefersDarkModeObs().pause();
prefersColorSchemeDarkObs().pause();
// Hide all layout boxes that do NOT contain the section to be printed.
layout?.forEachBox((box: any) => {
@@ -87,10 +87,10 @@ export async function printViewSection(layout: any, viewSection: ViewSectionRec)
prepareToPrint(false);
}
delete (window as any).afterPrintCallback;
prefersDarkModeObs().pause(false);
prefersColorSchemeDarkObs().pause(false);
// This may have changed while window.print() was blocking.
prefersDarkModeObs().set(prefersDarkMode());
prefersColorSchemeDarkObs().set(prefersColorSchemeDark());
});
// Running print on a timeout makes it possible to test printing using selenium, and doesn't

View File

@@ -7,11 +7,11 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {makeTestId} from 'app/client/lib/domUtils';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {
AccessTokenOptions, CursorPos, CustomSectionAPI, FetchSelectedOptions, GristDocAPI, GristView,
@@ -696,10 +696,10 @@ export class ConfigNotifier extends BaseEventSource {
* Notifies about theme changes. Exposed in the API as `onThemeChange`.
*/
export class ThemeNotifier extends BaseEventSource {
constructor(private _theme: Computed<Theme>) {
constructor() {
super();
this.autoDispose(
this._theme.addListener((newTheme, oldTheme) => {
gristThemeObs().addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
this._update();
@@ -715,7 +715,7 @@ export class ThemeNotifier extends BaseEventSource {
if (this.isDisposed()) { return; }
this._notify({
theme: this._theme.get(),
theme: gristThemeObs().get(),
fromReady,
});
}