gristlabs_grist-core/app/client/components/DropdownConditionEditor.ts
George Gevoian 3112433a58 (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
2024-04-26 16:57:55 -04:00

235 lines
7.2 KiB
TypeScript

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;
`);