mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -4,13 +4,22 @@ var TextEditor = require('app/client/widgets/TextEditor');
|
||||
|
||||
const {Autocomplete} = require('app/client/lib/autocomplete');
|
||||
const {ACIndexImpl, buildHighlightedDom} = require('app/client/lib/ACIndex');
|
||||
const {ChoiceItem, cssChoiceList, cssMatchText, cssPlusButton,
|
||||
cssPlusIcon} = require('app/client/widgets/ChoiceListEditor');
|
||||
const {makeT} = require('app/client/lib/localization');
|
||||
const {
|
||||
buildDropdownConditionFilter,
|
||||
ChoiceItem,
|
||||
cssChoiceList,
|
||||
cssMatchText,
|
||||
cssPlusButton,
|
||||
cssPlusIcon,
|
||||
} = require('app/client/widgets/ChoiceListEditor');
|
||||
const {icon} = require('app/client/ui2018/icons');
|
||||
const {menuCssClass} = require('app/client/ui2018/menus');
|
||||
const {testId, theme} = require('app/client/ui2018/cssVars');
|
||||
const {choiceToken, cssChoiceACItem} = require('app/client/widgets/ChoiceToken');
|
||||
const {dom, styled} = require('grainjs');
|
||||
const {icon} = require('../ui2018/icons');
|
||||
|
||||
const t = makeT('ChoiceEditor');
|
||||
|
||||
/**
|
||||
* ChoiceEditor - TextEditor with a dropdown for possible choices.
|
||||
@@ -18,15 +27,46 @@ const {icon} = require('../ui2018/icons');
|
||||
function ChoiceEditor(options) {
|
||||
TextEditor.call(this, options);
|
||||
|
||||
this.choices = options.field.widgetOptionsJson.peek().choices || [];
|
||||
this.choiceOptions = options.field.widgetOptionsJson.peek().choiceOptions || {};
|
||||
this.widgetOptionsJson = options.field.widgetOptionsJson;
|
||||
this.choices = this.widgetOptionsJson.peek().choices || [];
|
||||
this.choicesSet = new Set(this.choices);
|
||||
this.choiceOptions = this.widgetOptionsJson.peek().choiceOptions || {};
|
||||
|
||||
this.hasDropdownCondition = Boolean(options.field.dropdownCondition.peek()?.text);
|
||||
this.dropdownConditionError;
|
||||
|
||||
let acItems = this.choices.map(c => new ChoiceItem(c, false, false));
|
||||
if (this.hasDropdownCondition) {
|
||||
try {
|
||||
const dropdownConditionFilter = this.buildDropdownConditionFilter();
|
||||
acItems = acItems.filter((item) => dropdownConditionFilter(item));
|
||||
} catch (e) {
|
||||
acItems = [];
|
||||
this.dropdownConditionError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const acIndex = new ACIndexImpl(acItems);
|
||||
this._acOptions = {
|
||||
popperOptions: {
|
||||
placement: 'bottom'
|
||||
},
|
||||
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
|
||||
buildNoItemsMessage: this.buildNoItemsMessage.bind(this),
|
||||
search: (term) => this.maybeShowAddNew(acIndex.search(term), term),
|
||||
renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),
|
||||
getItemText: (item) => item.label,
|
||||
onClick: () => this.options.commands.fieldEditSave(),
|
||||
};
|
||||
|
||||
if (!options.readonly && options.field.viewSection().parentKey() === "single") {
|
||||
this.cellEditorDiv.classList.add(cssChoiceEditor.className);
|
||||
this.cellEditorDiv.appendChild(cssChoiceEditIcon('Dropdown'));
|
||||
}
|
||||
|
||||
// Whether to include a button to show a new choice.
|
||||
// TODO: Disable when the user cannot change column configuration.
|
||||
this.enableAddNew = true;
|
||||
this.enableAddNew = !this.hasDropdownCondition;
|
||||
}
|
||||
|
||||
dispose.makeDisposable(ChoiceEditor);
|
||||
@@ -66,20 +106,7 @@ ChoiceEditor.prototype.attach = function(cellElem) {
|
||||
// Don't create autocomplete if readonly.
|
||||
if (this.options.readonly) { return; }
|
||||
|
||||
const acItems = this.choices.map(c => new ChoiceItem(c, false, false));
|
||||
const acIndex = new ACIndexImpl(acItems);
|
||||
const acOptions = {
|
||||
popperOptions: {
|
||||
placement: 'bottom'
|
||||
},
|
||||
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
|
||||
search: (term) => this.maybeShowAddNew(acIndex.search(term), term),
|
||||
renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),
|
||||
getItemText: (item) => item.label,
|
||||
onClick: () => this.options.commands.fieldEditSave(),
|
||||
};
|
||||
|
||||
this.autocomplete = Autocomplete.create(this, this.textInput, acOptions);
|
||||
this.autocomplete = Autocomplete.create(this, this.textInput, this._acOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,11 +116,35 @@ ChoiceEditor.prototype.attach = function(cellElem) {
|
||||
ChoiceEditor.prototype.prepForSave = async function() {
|
||||
const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem();
|
||||
if (selectedItem && selectedItem.isNew) {
|
||||
const choices = this.options.field.widgetOptionsJson.prop('choices');
|
||||
const choices = this.widgetOptionsJson.prop('choices');
|
||||
await choices.saveOnly([...(choices.peek() || []), selectedItem.label]);
|
||||
}
|
||||
}
|
||||
|
||||
ChoiceEditor.prototype.buildDropdownConditionFilter = function() {
|
||||
const dropdownConditionCompiled = this.options.field.dropdownConditionCompiled.get();
|
||||
if (dropdownConditionCompiled?.kind !== 'success') {
|
||||
throw new Error('Dropdown condition is not compiled');
|
||||
}
|
||||
|
||||
return buildDropdownConditionFilter({
|
||||
dropdownConditionCompiled: dropdownConditionCompiled.result,
|
||||
docData: this.options.gristDoc.docData,
|
||||
tableId: this.options.field.tableId(),
|
||||
rowId: this.options.rowId,
|
||||
});
|
||||
}
|
||||
|
||||
ChoiceEditor.prototype.buildNoItemsMessage = function() {
|
||||
if (this.dropdownConditionError) {
|
||||
return t('Error in dropdown condition');
|
||||
} else if (this.hasDropdownCondition) {
|
||||
return t('No choices matching condition');
|
||||
} else {
|
||||
return t('No choices to select');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the search text does not match anything exactly, adds 'new' item to it.
|
||||
*
|
||||
@@ -103,15 +154,21 @@ ChoiceEditor.prototype.maybeShowAddNew = function(result, text) {
|
||||
// TODO: This logic is also mostly duplicated in ChoiceListEditor and ReferenceEditor.
|
||||
// See if there's anything common we can factor out and re-use.
|
||||
this.showAddNew = false;
|
||||
if (!this.enableAddNew) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const trimmedText = text.trim();
|
||||
if (!this.enableAddNew || !trimmedText) { return result; }
|
||||
if (!trimmedText || this.choicesSet.has(trimmedText)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const addNewItem = new ChoiceItem(trimmedText, false, false, true);
|
||||
if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.items.push(addNewItem);
|
||||
result.extraItems.push(addNewItem);
|
||||
this.showAddNew = true;
|
||||
|
||||
return result;
|
||||
|
||||
@@ -2,7 +2,9 @@ import {createGroup} from 'app/client/components/commands';
|
||||
import {ACIndexImpl, ACItem, ACResults,
|
||||
buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex';
|
||||
import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
|
||||
import {DocData} from 'app/client/models/DocData';
|
||||
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||
@@ -10,12 +12,15 @@ import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
|
||||
import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {csvEncodeRow} from 'app/common/csvFormat';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {CompiledPredicateFormula, EmptyRecordView} from 'app/common/PredicateFormula';
|
||||
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
|
||||
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
|
||||
import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {dom, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('ChoiceListEditor');
|
||||
|
||||
export class ChoiceItem implements ACItem, IToken {
|
||||
public cleanText: string = normalizeText(this.label);
|
||||
constructor(
|
||||
@@ -38,25 +43,37 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
private _inputSizer!: HTMLElement; // Part of _contentSizer to size the text input
|
||||
private _alignment: string;
|
||||
|
||||
private _widgetOptionsJson = this.options.field.widgetOptionsJson.peek();
|
||||
private _choices: string[] = this._widgetOptionsJson.choices || [];
|
||||
private _choicesSet: Set<string> = new Set(this._choices);
|
||||
private _choiceOptionsByName: ChoiceOptions = this._widgetOptionsJson.choiceOptions || {};
|
||||
|
||||
// Whether to include a button to show a new choice.
|
||||
// TODO: Disable when the user cannot change column configuration.
|
||||
private _enableAddNew: boolean = true;
|
||||
private _enableAddNew: boolean;
|
||||
private _showAddNew: boolean = false;
|
||||
|
||||
private _choiceOptionsByName: ChoiceOptions;
|
||||
private _hasDropdownCondition = Boolean(this.options.field.dropdownCondition.peek()?.text);
|
||||
private _dropdownConditionError: string | undefined;
|
||||
|
||||
constructor(protected options: FieldOptions) {
|
||||
super(options);
|
||||
|
||||
const choices: string[] = options.field.widgetOptionsJson.peek().choices || [];
|
||||
this._choiceOptionsByName = options.field.widgetOptionsJson
|
||||
.peek().choiceOptions || {};
|
||||
const acItems = choices.map(c => new ChoiceItem(c, false, false));
|
||||
const choiceSet = new Set(choices);
|
||||
let acItems = this._choices.map(c => new ChoiceItem(c, false, false));
|
||||
if (this._hasDropdownCondition) {
|
||||
try {
|
||||
const dropdownConditionFilter = this._buildDropdownConditionFilter();
|
||||
acItems = acItems.filter((item) => dropdownConditionFilter(item));
|
||||
} catch (e) {
|
||||
acItems = [];
|
||||
this._dropdownConditionError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const acIndex = new ACIndexImpl<ChoiceItem>(acItems);
|
||||
const acOptions: IAutocompleteOptions<ChoiceItem> = {
|
||||
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
|
||||
buildNoItemsMessage: this._buildNoItemsMessage.bind(this),
|
||||
search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term),
|
||||
renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc),
|
||||
getItemText: (item) => item.label,
|
||||
@@ -65,12 +82,13 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
|
||||
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
||||
|
||||
|
||||
// If starting to edit by typing in a string, ignore previous tokens.
|
||||
const cellValue = decodeObject(options.cellValue);
|
||||
const startLabels: unknown[] = options.editValue !== undefined || !Array.isArray(cellValue) ? [] : cellValue;
|
||||
const startTokens = startLabels.map(label => new ChoiceItem(
|
||||
String(label),
|
||||
!choiceSet.has(String(label)),
|
||||
!this._choicesSet.has(String(label)),
|
||||
String(label).trim() === ''
|
||||
));
|
||||
|
||||
@@ -87,7 +105,7 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
cssChoiceToken.cls('-invalid', item.isInvalid),
|
||||
cssChoiceToken.cls('-blank', item.isBlank),
|
||||
],
|
||||
createToken: label => new ChoiceItem(label, !choiceSet.has(label), label.trim() === ''),
|
||||
createToken: label => new ChoiceItem(label, !this._choicesSet.has(label), label.trim() === ''),
|
||||
acOptions,
|
||||
openAutocompleteOnFocus: true,
|
||||
readonly : options.readonly,
|
||||
@@ -118,6 +136,8 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
dom.prop('value', options.editValue || ''),
|
||||
this.commandGroup.attach(),
|
||||
);
|
||||
|
||||
this._enableAddNew = !this._hasDropdownCondition;
|
||||
}
|
||||
|
||||
public attach(cellElem: Element): void {
|
||||
@@ -150,7 +170,7 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
}
|
||||
|
||||
public getTextValue() {
|
||||
const values = this._tokenField.tokensObs.get().map(t => t.label);
|
||||
const values = this._tokenField.tokensObs.get().map(token => token.label);
|
||||
return csvEncodeRow(values, {prettier: true});
|
||||
}
|
||||
|
||||
@@ -164,7 +184,7 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
*/
|
||||
public async prepForSave() {
|
||||
const tokens = this._tokenField.tokensObs.get();
|
||||
const newChoices = tokens.filter(t => t.isNew).map(t => t.label);
|
||||
const newChoices = tokens.filter(({isNew}) => isNew).map(({label}) => label);
|
||||
if (newChoices.length > 0) {
|
||||
const choices = this.options.field.widgetOptionsJson.prop('choices');
|
||||
await choices.saveOnly([...(choices.peek() || []), ...new Set(newChoices)]);
|
||||
@@ -218,6 +238,30 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + 'px';
|
||||
}
|
||||
|
||||
private _buildDropdownConditionFilter() {
|
||||
const dropdownConditionCompiled = this.options.field.dropdownConditionCompiled.get();
|
||||
if (dropdownConditionCompiled?.kind !== 'success') {
|
||||
throw new Error('Dropdown condition is not compiled');
|
||||
}
|
||||
|
||||
return buildDropdownConditionFilter({
|
||||
dropdownConditionCompiled: dropdownConditionCompiled.result,
|
||||
docData: this.options.gristDoc.docData,
|
||||
tableId: this.options.field.tableId(),
|
||||
rowId: this.options.rowId,
|
||||
});
|
||||
}
|
||||
|
||||
private _buildNoItemsMessage(): string {
|
||||
if (this._dropdownConditionError) {
|
||||
return t('Error in dropdown condition');
|
||||
} else if (this._hasDropdownCondition) {
|
||||
return t('No choices matching condition');
|
||||
} else {
|
||||
return t('No choices to select');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the search text does not match anything exactly, adds 'new' item to it.
|
||||
*
|
||||
@@ -225,15 +269,21 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
*/
|
||||
private _maybeShowAddNew(result: ACResults<ChoiceItem>, text: string): ACResults<ChoiceItem> {
|
||||
this._showAddNew = false;
|
||||
if (!this._enableAddNew) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const trimmedText = text.trim();
|
||||
if (!this._enableAddNew || !trimmedText) { return result; }
|
||||
if (!trimmedText || this._choicesSet.has(trimmedText)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const addNewItem = new ChoiceItem(trimmedText, false, false, true);
|
||||
if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.items.push(addNewItem);
|
||||
result.extraItems.push(addNewItem);
|
||||
this._showAddNew = true;
|
||||
|
||||
return result;
|
||||
@@ -259,6 +309,24 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetACFilterFuncParams {
|
||||
dropdownConditionCompiled: CompiledPredicateFormula;
|
||||
docData: DocData;
|
||||
tableId: string;
|
||||
rowId: number;
|
||||
}
|
||||
|
||||
export function buildDropdownConditionFilter(
|
||||
params: GetACFilterFuncParams
|
||||
): (item: ChoiceItem) => boolean {
|
||||
const {dropdownConditionCompiled, docData, tableId, rowId} = params;
|
||||
const table = docData.getTable(tableId);
|
||||
if (!table) { throw new Error(`Table ${tableId} not found`); }
|
||||
|
||||
const rec = table.getRecord(rowId) || new EmptyRecordView();
|
||||
return (item: ChoiceItem) => dropdownConditionCompiled({rec, choice: item.label});
|
||||
}
|
||||
|
||||
const cssCellEditor = styled('div', `
|
||||
background-color: ${theme.cellEditorBg};
|
||||
font-family: var(--grist-font-family-data);
|
||||
|
||||
@@ -605,7 +605,6 @@ const cssButtonRow = styled('div', `
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
const cssDeleteButton = styled('div', `
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
FormOptionsSortConfig,
|
||||
FormSelectConfig,
|
||||
} from 'app/client/components/Forms/FormConfig';
|
||||
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
@@ -82,11 +83,15 @@ export class ChoiceTextBox extends NTextBox {
|
||||
return [
|
||||
super.buildConfigDom(),
|
||||
this.buildChoicesConfigDom(),
|
||||
dom.create(DropdownConditionConfig, this.field),
|
||||
];
|
||||
}
|
||||
|
||||
public buildTransformConfigDom() {
|
||||
return this.buildConfigDom();
|
||||
return [
|
||||
super.buildConfigDom(),
|
||||
this.buildChoicesConfigDom(),
|
||||
];
|
||||
}
|
||||
|
||||
public buildFormConfigDom() {
|
||||
|
||||
@@ -4,7 +4,8 @@ import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {RuleOwner} from 'app/client/models/RuleOwner';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
|
||||
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
||||
import {cssFieldFormula} from 'app/client/ui/RightPanelStyles';
|
||||
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
@@ -180,10 +181,11 @@ export class ConditionalStyle extends Disposable {
|
||||
column: ColumnRec,
|
||||
hasError: Observable<boolean>
|
||||
) {
|
||||
return cssFieldFormula(
|
||||
return dom.create(buildHighlightedCode,
|
||||
formula,
|
||||
{ gristTheme: this._gristDoc.currentTheme, maxLines: 1 },
|
||||
{ maxLines: 1 },
|
||||
dom.cls('formula_field_sidepane'),
|
||||
dom.cls(cssFieldFormula.className),
|
||||
dom.cls(cssErrorBorder.className, hasError),
|
||||
{ tabIndex: '-1' },
|
||||
dom.on('focus', (_, refElem) => {
|
||||
|
||||
@@ -10,8 +10,8 @@ import {dom, styled} from 'grainjs';
|
||||
export function createMobileButtons(commands: IEditorCommandGroup) {
|
||||
// TODO A better check may be to detect a physical keyboard or touch support.
|
||||
return isDesktop() ? null : [
|
||||
cssCancelBtn(cssIconWrap(cssFinishIcon('CrossSmall')), dom.on('click', commands.fieldEditCancel)),
|
||||
cssSaveBtn(cssIconWrap(cssFinishIcon('Tick')), dom.on('click', commands.fieldEditSaveHere)),
|
||||
cssCancelBtn(cssIconWrap(cssFinishIcon('CrossSmall')), dom.on('mousedown', commands.fieldEditCancel)),
|
||||
cssSaveBtn(cssIconWrap(cssFinishIcon('Tick')), dom.on('mousedown', commands.fieldEditSaveHere)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||
import {ChatMessage} from 'app/client/models/entities/ColumnRec';
|
||||
import {HAS_FORMULA_ASSISTANT, WHICH_FORMULA_ASSISTANT} from 'app/client/models/features';
|
||||
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
||||
import {buildCodeHighlighter, buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
||||
import {autoGrow} from 'app/client/ui/forms';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
import {createUserImage} from 'app/client/ui/UserImage';
|
||||
import {basicButton, bigPrimaryButtonLink, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {gristThemeObs} from 'app/client/ui2018/theme';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {loadingDots} from 'app/client/ui2018/loaders';
|
||||
@@ -1009,26 +1010,19 @@ class ChatHistory extends Disposable {
|
||||
* Renders the message as markdown if possible, otherwise as a code block.
|
||||
*/
|
||||
private _render(message: string, ...args: DomElementArg[]) {
|
||||
const doc = this._options.gristDoc;
|
||||
if (this.supportsMarkdown()) {
|
||||
return dom('div',
|
||||
(el) => subscribeElem(el, doc.currentTheme, () => {
|
||||
(el) => subscribeElem(el, gristThemeObs(), async () => {
|
||||
const highlightCode = await buildCodeHighlighter({maxLines: 60});
|
||||
const content = sanitizeHTML(marked(message, {
|
||||
highlight: (code) => {
|
||||
const codeBlock = buildHighlightedCode(code, {
|
||||
gristTheme: doc.currentTheme,
|
||||
maxLines: 60,
|
||||
});
|
||||
return codeBlock.innerHTML;
|
||||
},
|
||||
highlight: (code) => highlightCode(code)
|
||||
}));
|
||||
el.innerHTML = content;
|
||||
}),
|
||||
...args
|
||||
);
|
||||
} else {
|
||||
return buildHighlightedCode(message, {
|
||||
gristTheme: doc.currentTheme,
|
||||
return dom.create(buildHighlightedCode, message, {
|
||||
maxLines: 100,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,15 +74,13 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
this._aceEditor = AceEditor.create({
|
||||
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
|
||||
// and _editorPlacement created.
|
||||
column: options.column,
|
||||
calcSize: this._calcSize.bind(this),
|
||||
gristDoc: options.gristDoc,
|
||||
saveValueOnBlurEvent: !options.readonly,
|
||||
editorState : this.editorState,
|
||||
readonly: options.readonly
|
||||
readonly: options.readonly,
|
||||
getSuggestions: this._getSuggestions.bind(this),
|
||||
});
|
||||
|
||||
|
||||
// For editable editor we will grab the cursor when we are in the formula editing mode.
|
||||
const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor };
|
||||
const isActive = Computed.create(this, use => Boolean(use(editingFormula)));
|
||||
@@ -201,10 +199,7 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
cssFormulaEditor.cls('-detached', this.isDetached),
|
||||
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
|
||||
this._aceEditor.buildDom((aceObj: any) => {
|
||||
aceObj.setFontSize(11);
|
||||
aceObj.setHighlightActiveLine(false);
|
||||
aceObj.getSession().setUseWrapMode(false);
|
||||
aceObj.renderer.setPadding(0);
|
||||
initializeAceOptions(aceObj);
|
||||
const val = initialValue;
|
||||
const pos = Math.min(options.cursorPos, val.length);
|
||||
this._aceEditor.setValue(val, pos);
|
||||
@@ -405,6 +400,17 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
return result;
|
||||
}
|
||||
|
||||
private _getSuggestions(prefix: string) {
|
||||
const section = this.options.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.options.column.colId();
|
||||
const rowId = section.activeRowId();
|
||||
return this.options.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId);
|
||||
}
|
||||
|
||||
// TODO: update regexes to unicode?
|
||||
private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {
|
||||
// Don't do anything when we are readonly.
|
||||
@@ -714,6 +720,13 @@ export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, or
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
export function initializeAceOptions(aceObj: any) {
|
||||
aceObj.setFontSize(11);
|
||||
aceObj.setHighlightActiveLine(false);
|
||||
aceObj.getSession().setUseWrapMode(false);
|
||||
aceObj.renderer.setPadding(0);
|
||||
}
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
margin: -3px 4px 0 4px;
|
||||
--icon-color: ${colors.slate};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
FormOptionsSortConfig,
|
||||
FormSelectConfig
|
||||
} from 'app/client/components/Forms/FormConfig';
|
||||
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {TableRec} from 'app/client/models/DocModel';
|
||||
@@ -55,6 +56,7 @@ export class Reference extends NTextBox {
|
||||
public buildConfigDom() {
|
||||
return [
|
||||
this.buildTransformConfigDom(),
|
||||
dom.create(DropdownConditionConfig, this.field),
|
||||
cssLabel(t('CELL FORMAT')),
|
||||
super.buildConfigDom(),
|
||||
];
|
||||
|
||||
@@ -11,7 +11,6 @@ import { nocaseEqual, ReferenceUtils } from 'app/client/lib/ReferenceUtils';
|
||||
import { undef } from 'app/common/gutil';
|
||||
import { styled } from 'grainjs';
|
||||
|
||||
|
||||
/**
|
||||
* A ReferenceEditor offers an autocomplete of choices from the referenced table.
|
||||
*/
|
||||
@@ -28,7 +27,12 @@ export class ReferenceEditor extends NTextEditor {
|
||||
this._utils = new ReferenceUtils(options.field, docData);
|
||||
|
||||
const vcol = this._utils.visibleColModel;
|
||||
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
|
||||
this._enableAddNew = (
|
||||
vcol &&
|
||||
!vcol.isRealFormula() &&
|
||||
!!vcol.colId() &&
|
||||
!this._utils.hasDropdownCondition
|
||||
);
|
||||
|
||||
// Decorate the editor to look like a reference column value (with a "link" icon).
|
||||
// But not on readonly mode - here we will reuse default decoration
|
||||
@@ -65,7 +69,8 @@ export class ReferenceEditor extends NTextEditor {
|
||||
// don't create autocomplete for readonly mode
|
||||
if (this.options.readonly) { return; }
|
||||
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
|
||||
menuCssClass: menuCssClass + ' ' + cssRefList.className,
|
||||
menuCssClass: `${menuCssClass} ${cssRefList.className} test-autocomplete`,
|
||||
buildNoItemsMessage: () => this._utils.buildNoItemsMessage(),
|
||||
search: this._doSearch.bind(this),
|
||||
renderItem: this._renderItem.bind(this),
|
||||
getItemText: (item) => item.text,
|
||||
@@ -110,7 +115,7 @@ export class ReferenceEditor extends NTextEditor {
|
||||
* Also see: prepForSave.
|
||||
*/
|
||||
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
|
||||
const result = this._utils.autocompleteSearch(text);
|
||||
const result = this._utils.autocompleteSearch(text, this.options.rowId);
|
||||
|
||||
this._showAddNew = false;
|
||||
if (!this._enableAddNew || !text) { return result; }
|
||||
@@ -120,7 +125,7 @@ export class ReferenceEditor extends NTextEditor {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.items.push({rowId: 'new', text, cleanText});
|
||||
result.extraItems.push({rowId: 'new', text, cleanText});
|
||||
this._showAddNew = true;
|
||||
|
||||
return result;
|
||||
|
||||
@@ -59,10 +59,16 @@ export class ReferenceListEditor extends NewBaseEditor {
|
||||
this._utils = new ReferenceUtils(options.field, docData);
|
||||
|
||||
const vcol = this._utils.visibleColModel;
|
||||
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
|
||||
this._enableAddNew = (
|
||||
vcol &&
|
||||
!vcol.isRealFormula() &&
|
||||
!!vcol.colId() &&
|
||||
!this._utils.hasDropdownCondition
|
||||
);
|
||||
|
||||
const acOptions: IAutocompleteOptions<ReferenceItem> = {
|
||||
menuCssClass: `${menuCssClass} ${cssRefList.className}`,
|
||||
menuCssClass: `${menuCssClass} ${cssRefList.className} test-autocomplete`,
|
||||
buildNoItemsMessage: () => this._utils.buildNoItemsMessage(),
|
||||
search: this._doSearch.bind(this),
|
||||
renderItem: this._renderItem.bind(this),
|
||||
getItemText: (item) => item.text,
|
||||
@@ -166,12 +172,14 @@ export class ReferenceListEditor extends NewBaseEditor {
|
||||
}
|
||||
|
||||
public getCellValue(): CellValue {
|
||||
const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? t.rowId : t.text);
|
||||
const rowIds = this._tokenField.tokensObs.get()
|
||||
.map(token => typeof token.rowId === 'number' ? token.rowId : token.text);
|
||||
return encodeObject(rowIds);
|
||||
}
|
||||
|
||||
public getTextValue(): string {
|
||||
const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? String(t.rowId) : t.text);
|
||||
const rowIds = this._tokenField.tokensObs.get()
|
||||
.map(token => typeof token.rowId === 'number' ? String(token.rowId) : token.text);
|
||||
return csvEncodeRow(rowIds, {prettier: true});
|
||||
}
|
||||
|
||||
@@ -184,19 +192,19 @@ export class ReferenceListEditor extends NewBaseEditor {
|
||||
*/
|
||||
public async prepForSave() {
|
||||
const tokens = this._tokenField.tokensObs.get();
|
||||
const newValues = tokens.filter(t => t.rowId === 'new');
|
||||
const newValues = tokens.filter(({rowId})=> rowId === 'new');
|
||||
if (newValues.length === 0) { return; }
|
||||
|
||||
// Add the new items to the referenced table.
|
||||
const colInfo = {[this._utils.visibleColId]: newValues.map(t => t.text)};
|
||||
const colInfo = {[this._utils.visibleColId]: newValues.map(({text}) => text)};
|
||||
const rowIds = await this._utils.tableData.sendTableAction(
|
||||
["BulkAddRecord", new Array(newValues.length).fill(null), colInfo]
|
||||
);
|
||||
|
||||
// Update the TokenField tokens with the returned row ids.
|
||||
let i = 0;
|
||||
const newTokens = tokens.map(t => {
|
||||
return t.rowId === 'new' ? new ReferenceItem(t.text, rowIds[i++]) : t;
|
||||
const newTokens = tokens.map(token => {
|
||||
return token.rowId === 'new' ? new ReferenceItem(token.text, rowIds[i++]) : token;
|
||||
});
|
||||
this._tokenField.setTokens(newTokens);
|
||||
}
|
||||
@@ -254,11 +262,12 @@ export class ReferenceListEditor extends NewBaseEditor {
|
||||
* Also see: prepForSave.
|
||||
*/
|
||||
private async _doSearch(text: string): Promise<ACResults<ReferenceItem>> {
|
||||
const {items, selectIndex, highlightFunc} = this._utils.autocompleteSearch(text);
|
||||
const {items, selectIndex, highlightFunc} = this._utils.autocompleteSearch(text, this.options.rowId);
|
||||
const result: ACResults<ReferenceItem> = {
|
||||
selectIndex,
|
||||
highlightFunc,
|
||||
items: items.map(i => new ReferenceItem(i.text, i.rowId))
|
||||
items: items.map(i => new ReferenceItem(i.text, i.rowId)),
|
||||
extraItems: [],
|
||||
};
|
||||
|
||||
this._showAddNew = false;
|
||||
@@ -269,7 +278,7 @@ export class ReferenceListEditor extends NewBaseEditor {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.items.push(new ReferenceItem(text, 'new'));
|
||||
result.extraItems.push(new ReferenceItem(text, 'new'));
|
||||
this._showAddNew = true;
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user