(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

@@ -1,13 +1,12 @@
import ace, {Ace} from 'ace-builds';
import {setupAceEditorCompletions} from 'app/client/components/AceEditorCompletions';
import {theme} from 'app/client/ui2018/cssVars';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, DomArg, Listener, Observable, styled} from 'grainjs';
import {dom, DomArg, Observable, styled} from 'grainjs';
import debounce from 'lodash/debounce';
export interface ACLFormulaOptions {
gristTheme: Computed<Theme>;
initialValue: string;
readOnly: boolean;
placeholder: DomArg;
@@ -22,19 +21,15 @@ export function aclFormulaEditor(options: ACLFormulaOptions) {
const editor: Ace.Editor = ace.edit(editorElem);
// Set various editor options.
function setAceTheme(gristTheme: Theme) {
const {enableCustomCss} = getGristConfig();
const gristAppearance = gristTheme.appearance;
const aceTheme = gristAppearance === 'dark' && !enableCustomCss ? 'dracula' : 'chrome';
function setAceTheme(newTheme: Theme) {
const {appearance} = newTheme;
const aceTheme = appearance === 'dark' ? 'dracula' : 'chrome';
editor.setTheme(`ace/theme/${aceTheme}`);
}
setAceTheme(options.gristTheme.get());
let themeListener: Listener | undefined;
if (!getGristConfig().enableCustomCss) {
themeListener = options.gristTheme.addListener((gristTheme) => {
setAceTheme(gristTheme);
});
}
setAceTheme(gristThemeObs().get());
const themeListener = gristThemeObs().addListener((newTheme) => {
setAceTheme(newTheme);
});
// ACE editor resizes automatically when maxLines is set.
editor.setOptions({enableLiveAutocompletion: true, maxLines: 10});
editor.renderer.setShowGutter(false); // Default line numbers to hidden

View File

@@ -36,14 +36,13 @@ import {ACLRuleCollection, isSchemaEditResource, SPECIAL_RULES_TABLE_ID} from 'a
import {AclRuleProblem, AclTableDescription, getTableTitle} from 'app/common/ActiveDocAPI';
import {BulkColValues, getColValues, RowRecord, UserAction} from 'app/common/DocActions';
import {
FormulaProperties,
getFormulaProperties,
RulePart,
RuleSet,
UserAttributeRule
} from 'app/common/GranularAccessClause';
import {isHiddenCol} from 'app/common/gristTypes';
import {isNonNullish, unwrap} from 'app/common/gutil';
import {getPredicateFormulaProperties, PredicateFormulaProperties} from 'app/common/PredicateFormula';
import {SchemaTypes} from 'app/common/schema';
import {MetaRowRecord} from 'app/common/TableData';
import {
@@ -496,7 +495,7 @@ export class AccessRules extends Disposable {
removeItem(this._userAttrRules, userAttr);
}
public async checkAclFormula(text: string): Promise<FormulaProperties> {
public async checkAclFormula(text: string): Promise<PredicateFormulaProperties> {
if (text) {
return this.gristDoc.docComm.checkAclFormula(text);
}
@@ -1465,7 +1464,6 @@ class ObsUserAttributeRule extends Disposable {
cssColumnGroup(
cssCell1(
aclFormulaEditor({
gristTheme: this._accessRules.gristDoc.currentTheme,
initialValue: this._charId.get(),
readOnly: false,
setValue: (text) => this._setUserAttr(text),
@@ -1598,7 +1596,8 @@ class ObsRulePart extends Disposable {
// If the formula failed validation, the error message to show. Blank if valid.
private _formulaError = Observable.create(this, '');
private _formulaProperties = Observable.create<FormulaProperties>(this, getAclFormulaProperties(this._rulePart));
private _formulaProperties = Observable.create<PredicateFormulaProperties>(this,
getAclFormulaProperties(this._rulePart));
// Error message if any validation failed.
private _error: Computed<string>;
@@ -1618,7 +1617,7 @@ class ObsRulePart extends Disposable {
this._error = Computed.create(this, (use) => {
return use(this._formulaError) ||
this._warnInvalidColIds(use(this._formulaProperties).usedColIds) ||
this._warnInvalidColIds(use(this._formulaProperties).recColIds) ||
( !this._ruleSet.isLastCondition(use, this) &&
use(this._aclFormula) === '' &&
permissionSetToText(use(this._permissions)) !== '' ?
@@ -1690,7 +1689,6 @@ class ObsRulePart extends Disposable {
cssCell2(
wide ? cssCell4.cls('') : null,
aclFormulaEditor({
gristTheme: this._ruleSet.accessRules.gristDoc.currentTheme,
initialValue: this._aclFormula.get(),
readOnly: this.isBuiltIn(),
setValue: (value) => this._setAclFormula(value),
@@ -1913,9 +1911,9 @@ function getChangedStatus(value: boolean): RuleStatus {
return value ? RuleStatus.ChangedValid : RuleStatus.Unchanged;
}
function getAclFormulaProperties(part?: RulePart): FormulaProperties {
function getAclFormulaProperties(part?: RulePart): PredicateFormulaProperties {
const aclFormulaParsed = part?.origRecord?.aclFormulaParsed;
return aclFormulaParsed ? getFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};
return aclFormulaParsed ? getPredicateFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};
}
// Return a rule set if it applies to one of the specified columns.

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,
});
}

View File

@@ -51,6 +51,9 @@ export interface ACResults<Item extends ACItem> {
// Matching items in order from best match to worst.
items: Item[];
// Additional items to show (e.g. the "Add New" item, for Choice and Reference fields).
extraItems: Item[];
// May be used to highlight matches using buildHighlightedDom().
highlightFunc: HighlightFunc;
@@ -159,7 +162,7 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
if (!cleanedSearchText) {
// In this case we are just returning the first few items.
return {items, highlightFunc: highlightNone, selectIndex: -1};
return {items, extraItems: [], highlightFunc: highlightNone, selectIndex: -1};
}
const highlightFunc = highlightMatches.bind(null, searchWords);
@@ -170,7 +173,7 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
if (selectIndex >= 0 && !startsWithText(items[selectIndex], cleanedSearchText, searchWords)) {
selectIndex = -1;
}
return {items, highlightFunc, selectIndex};
return {items, extraItems: [], highlightFunc, selectIndex};
}
/**

View File

@@ -98,7 +98,7 @@ export function buildACMemberEmail(
label: text,
id: 0,
};
results.items.push(newObject);
results.extraItems.push(newObject);
}
return results;
};

View File

@@ -4,8 +4,6 @@ import {SafeBrowser} from 'app/client/lib/SafeBrowser';
import {ActiveDocAPI} from 'app/common/ActiveDocAPI';
import {LocalPlugin} from 'app/common/plugin';
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
import {Theme} from 'app/common/ThemePrefs';
import {Computed} from 'grainjs';
import {Rpc} from 'grain-rpc';
/**
@@ -18,7 +16,6 @@ export class DocPluginManager {
private _clientScope = this._options.clientScope;
private _docComm = this._options.docComm;
private _localPlugins = this._options.plugins;
private _theme = this._options.theme;
private _untrustedContentOrigin = this._options.untrustedContentOrigin;
constructor(private _options: {
@@ -26,7 +23,6 @@ export class DocPluginManager {
untrustedContentOrigin: string,
docComm: ActiveDocAPI,
clientScope: ClientScope,
theme: Computed<Theme>,
}) {
this.pluginsList = [];
for (const plugin of this._localPlugins) {
@@ -38,7 +34,6 @@ export class DocPluginManager {
clientScope: this._clientScope,
untrustedContentOrigin: this._untrustedContentOrigin,
mainPath: components.safeBrowser,
theme: this._theme,
});
if (components.safeBrowser) {
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);

View File

@@ -2,8 +2,6 @@ import {ClientScope} from 'app/client/components/ClientScope';
import {SafeBrowser} from 'app/client/lib/SafeBrowser';
import {LocalPlugin} from 'app/common/plugin';
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
import {Theme} from 'app/common/ThemePrefs';
import {Computed} from 'grainjs';
/**
* Home plugins are all plugins that contributes to a general Grist management tasks.
@@ -19,9 +17,8 @@ export class HomePluginManager {
localPlugins: LocalPlugin[],
untrustedContentOrigin: string,
clientScope: ClientScope,
theme: Computed<Theme>,
}) {
const {localPlugins, untrustedContentOrigin, clientScope, theme} = options;
const {localPlugins, untrustedContentOrigin, clientScope} = options;
this.pluginsList = [];
for (const plugin of localPlugins) {
try {
@@ -41,7 +38,6 @@ export class HomePluginManager {
clientScope,
untrustedContentOrigin,
mainPath: components.safeBrowser,
theme,
});
if (components.safeBrowser) {
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);

View File

@@ -1,22 +1,36 @@
import {ACIndex, ACResults} from 'app/client/lib/ACIndex';
import {makeT} from 'app/client/lib/localization';
import {ICellItem} from 'app/client/models/ColumnACIndexes';
import {ColumnCache} from 'app/client/models/ColumnCache';
import {DocData} from 'app/client/models/DocData';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {TableData} from 'app/client/models/TableData';
import {getReferencedTableId, isRefListType} from 'app/common/gristTypes';
import {EmptyRecordView} from 'app/common/PredicateFormula';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {Disposable, dom, Observable} from 'grainjs';
const t = makeT('ReferenceUtils');
/**
* Utilities for common operations involving Ref[List] fields.
*/
export class ReferenceUtils {
export class ReferenceUtils extends Disposable {
public readonly refTableId: string;
public readonly tableData: TableData;
public readonly visibleColFormatter: BaseFormatter;
public readonly visibleColModel: ColumnRec;
public readonly visibleColId: string;
public readonly isRefList: boolean;
public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text);
private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>;
private _dropdownConditionError = Observable.create<string | null>(this, null);
constructor(public readonly field: ViewFieldRec, private readonly _docData: DocData) {
super();
constructor(public readonly field: ViewFieldRec, docData: DocData) {
const colType = field.column().type();
const refTableId = getReferencedTableId(colType);
if (!refTableId) {
@@ -24,7 +38,7 @@ export class ReferenceUtils {
}
this.refTableId = refTableId;
const tableData = docData.getTable(refTableId);
const tableData = _docData.getTable(refTableId);
if (!tableData) {
throw new Error("Invalid referenced table " + refTableId);
}
@@ -34,6 +48,8 @@ export class ReferenceUtils {
this.visibleColModel = field.visibleColModel();
this.visibleColId = this.visibleColModel.colId() || 'id';
this.isRefList = isRefListType(colType);
this._columnCache = new ColumnCache<ACIndex<ICellItem>>(this.tableData);
}
public idToText(value: unknown) {
@@ -43,10 +59,86 @@ export class ReferenceUtils {
return String(value || '');
}
public autocompleteSearch(text: string) {
const acIndex = this.tableData.columnACIndexes.getColACIndex(this.visibleColId, this.visibleColFormatter);
/**
* Searches the autocomplete index for the given `text`, returning
* all matching results and related metadata.
*
* If a dropdown condition is set, results are dependent on the `rowId`
* that the autocomplete dropdown is open in. Otherwise, `rowId` has no
* effect.
*/
public autocompleteSearch(text: string, rowId: number): ACResults<ICellItem> {
let acIndex: ACIndex<ICellItem>;
if (this.hasDropdownCondition) {
try {
acIndex = this._getDropdownConditionACIndex(rowId);
} catch (e) {
this._dropdownConditionError?.set(e);
return {items: [], extraItems: [], highlightFunc: () => [], selectIndex: -1};
}
} else {
acIndex = this.tableData.columnACIndexes.getColACIndex(
this.visibleColId,
this.visibleColFormatter,
);
}
return acIndex.search(text);
}
public buildNoItemsMessage() {
return dom.domComputed(use => {
const error = use(this._dropdownConditionError);
if (error) { return t('Error in dropdown condition'); }
return this.hasDropdownCondition
? t('No choices matching condition')
: t('No choices to select');
});
}
/**
* Returns a column index for the visible column, filtering the items in the
* index according to the set dropdown condition.
*
* This method is similar to `this.tableData.columnACIndexes.getColACIndex`,
* but whereas that method caches indexes globally, this method does so
* locally (as a new instances of this class is created each time a Reference
* or Reference List editor is created).
*
* It's important that this method be used when a dropdown condition is set,
* as items in indexes that don't satisfy the dropdown condition need to be
* filtered.
*/
private _getDropdownConditionACIndex(rowId: number) {
return this._columnCache.getValue(
this.visibleColId,
() => this.tableData.columnACIndexes.buildColACIndex(
this.visibleColId,
this.visibleColFormatter,
this._buildDropdownConditionACFilter(rowId)
)
);
}
private _buildDropdownConditionACFilter(rowId: number) {
const dropdownConditionCompiled = this.field.dropdownConditionCompiled.get();
if (dropdownConditionCompiled?.kind !== 'success') {
throw new Error('Dropdown condition is not compiled');
}
const tableId = this.field.tableId.peek();
const table = this._docData.getTable(tableId);
if (!table) { throw new Error(`Table ${tableId} not found`); }
const {result: predicate} = dropdownConditionCompiled;
const rec = table.getRecord(rowId) || new EmptyRecordView();
return (item: ICellItem) => {
const choice = item.rowId === 'new' ? new EmptyRecordView() : this.tableData.getRecord(item.rowId);
if (!choice) { throw new Error(`Reference ${item.rowId} not found`); }
return predicate({rec, choice});
};
}
}
export function nocaseEqual(a: string, b: string) {

View File

@@ -33,6 +33,7 @@ import { ClientScope } from 'app/client/components/ClientScope';
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
import dom from 'app/client/lib/dom';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { gristThemeObs } from 'app/client/ui2018/theme';
import { ActionRouter } from 'app/common/ActionRouter';
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
import { tbind } from 'app/common/tbind';
@@ -41,7 +42,7 @@ import { getOriginUrl } from 'app/common/urlUtils';
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
import { checkers } from 'app/plugin/TypeCheckers';
import { Computed, dom as grainjsDom, Observable } from 'grainjs';
import { dom as grainjsDom, Observable } from 'grainjs';
import { IMsgCustom, IMsgRpcCall, IRpcLogger, MsgType, Rpc } from 'grain-rpc';
import { Disposable } from './dispose';
import isEqual from 'lodash/isEqual';
@@ -73,8 +74,6 @@ export class SafeBrowser extends BaseComponent {
new IframeProcess(safeBrowser, rpc, src);
}
public theme = this._options.theme;
// All view processes. This is not used anymore to dispose all processes on deactivation (this is
// now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
// events to all processes (such as doc actions which will need soon).
@@ -94,7 +93,6 @@ export class SafeBrowser extends BaseComponent {
pluginInstance: PluginInstance,
clientScope: ClientScope,
untrustedContentOrigin: string,
theme: Computed<Theme>,
mainPath?: string,
baseLogger?: BaseLogger,
rpcLogger?: IRpcLogger,
@@ -312,7 +310,7 @@ class IframeProcess extends ViewProcess {
const listener = async (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
if (event.data.mtype === MsgType.Ready) {
await this._sendTheme({theme: safeBrowser.theme.get(), fromReady: true});
await this._sendTheme({theme: gristThemeObs().get(), fromReady: true});
}
if (event.data.data?.message === 'themeInitialized') {
@@ -328,15 +326,11 @@ class IframeProcess extends ViewProcess {
});
this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
if (safeBrowser.theme) {
this.autoDispose(
safeBrowser.theme.addListener(async (newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
this.autoDispose(gristThemeObs().addListener(async (newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
await this._sendTheme({theme: newTheme});
})
);
}
await this._sendTheme({theme: newTheme});
}));
}
private async _sendTheme({theme, fromReady = false}: {theme: Theme, fromReady?: boolean}) {

View File

@@ -4,7 +4,8 @@
import {createPopper, Modifier, Instance as Popper, Options as PopperOptions} from '@popperjs/core';
import {ACItem, ACResults, HighlightFunc} from 'app/client/lib/ACIndex';
import {reportError} from 'app/client/models/errors';
import {Disposable, dom, EventCB, IDisposable} from 'grainjs';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {Disposable, dom, DomContents, EventCB, IDisposable} from 'grainjs';
import {obsArray, onKeyElem, styled} from 'grainjs';
import merge = require('lodash/merge');
import maxSize from 'popper-max-size-modifier';
@@ -26,6 +27,9 @@ export interface IAutocompleteOptions<Item extends ACItem> {
// Defaults to the document body.
attach?: Element|string|null;
// If provided, builds and shows the message when there are no items (excluding any extra items).
buildNoItemsMessage?: () => DomContents;
// Given a search term, return the list of Items to render.
search(searchText: string): Promise<ACResults<Item>>;
@@ -46,7 +50,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
// The UL element containing the actual menu items.
protected _menuContent: HTMLElement;
// Index into _items as well as into _menuContent, -1 if nothing selected.
// Index into _menuContent, -1 if nothing selected.
protected _selectedIndex: number = -1;
// Currently selected element.
@@ -56,6 +60,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
private _mouseOver: {reset(): void};
private _lastAsTyped: string;
private _items = this.autoDispose(obsArray<Item>([]));
private _extraItems = this.autoDispose(obsArray<Item>([]));
private _highlightFunc: HighlightFunc;
constructor(
@@ -65,14 +70,19 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
super();
const content = cssMenuWrap(
this._menuContent = cssMenu({class: _options.menuCssClass || ''},
dom.forEach(this._items, (item) => _options.renderItem(item, this._highlightFunc)),
cssMenu(
{class: _options.menuCssClass || ''},
dom.style('min-width', _triggerElem.getBoundingClientRect().width + 'px'),
dom.on('mouseleave', (ev) => this._setSelected(-1, true)),
dom.on('click', (ev) => {
this._setSelected(this._findTargetItem(ev.target), true);
if (_options.onClick) { _options.onClick(); }
})
this._maybeShowNoItemsMessage(),
this._menuContent = dom('div',
dom.forEach(this._items, (item) => _options.renderItem(item, this._highlightFunc)),
dom.forEach(this._extraItems, (item) => _options.renderItem(item, this._highlightFunc)),
dom.on('mouseleave', (ev) => this._setSelected(-1, true)),
dom.on('click', (ev) => {
this._setSelected(this._findTargetItem(ev.target), true);
if (_options.onClick) { _options.onClick(); }
}),
),
),
// Prevent trigger element from being blurred on click.
dom.on('mousedown', (ev) => ev.preventDefault()),
@@ -104,7 +114,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
}
public getSelectedItem(): Item|undefined {
return this._items.get()[this._selectedIndex];
return this._allItems[this._selectedIndex];
}
public search(findMatch?: (items: Item[]) => number) {
@@ -145,7 +155,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
private _getNext(step: 1 | -1): number {
// Pretend there is an extra element at the end to mean "nothing selected".
const xsize = this._items.get().length + 1;
const xsize = this._allItems.length + 1;
const next = (this._selectedIndex + step + xsize) % xsize;
return (next === xsize - 1) ? -1 : next;
}
@@ -157,6 +167,7 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
const acResults = await this._options.search(inputVal);
this._highlightFunc = acResults.highlightFunc;
this._items.set(acResults.items);
this._extraItems.set(acResults.extraItems);
// Plain update() (which is deferred) may be better, but if _setSelected() causes scrolling
// before the positions are updated, it causes the entire page to scroll horizontally.
@@ -166,12 +177,24 @@ export class Autocomplete<Item extends ACItem> extends Disposable {
let index: number;
if (findMatch) {
index = findMatch(this._items.get());
index = findMatch(this._allItems);
} else {
index = inputVal ? acResults.selectIndex : -1;
}
this._setSelected(index, false);
}
private get _allItems() {
return [...this._items.get(), ...this._extraItems.get()];
}
private _maybeShowNoItemsMessage() {
const {buildNoItemsMessage} = this._options;
if (!buildNoItemsMessage) { return null; }
return dom.maybe(use => use(this._items).length === 0, () =>
cssNoItemsMessage(buildNoItemsMessage(), testId('autocomplete-no-items-message')));
}
}
@@ -253,3 +276,10 @@ const cssMenuWrap = styled('div', `
flex-direction: column;
outline: none;
`);
const cssNoItemsMessage = styled('div', `
color: ${theme.lightText};
padding: var(--weaseljs-menu-item-padding, 8px 24px);
text-align: center;
user-select: none;
`);

View File

@@ -6,17 +6,20 @@ import * as GristDocModule from 'app/client/components/GristDoc';
import * as ViewPane from 'app/client/components/ViewPane';
import * as UserManagerModule from 'app/client/ui/UserManager';
import * as searchModule from 'app/client/ui2018/search';
import * as ace from 'ace-builds';
import * as momentTimezone from 'moment-timezone';
import * as plotly from 'plotly.js';
export type PlotlyType = typeof plotly;
export type Ace = typeof ace;
export type MomentTimezone = typeof momentTimezone;
export type PlotlyType = typeof plotly;
export function loadAccountPage(): Promise<typeof AccountPageModule>;
export function loadActivationPage(): Promise<typeof ActivationPageModule>;
export function loadBillingPage(): Promise<typeof BillingPageModule>;
export function loadAdminPanel(): Promise<typeof AdminPanelModule>;
export function loadGristDoc(): Promise<typeof GristDocModule>;
export function loadAce(): Promise<Ace>;
export function loadMomentTimezone(): Promise<MomentTimezone>;
export function loadPlotly(): Promise<PlotlyType>;
export function loadSearch(): Promise<typeof searchModule>;

View File

@@ -13,6 +13,17 @@ exports.loadAdminPanel = () => import('app/client/ui/AdminPanel' /* webpackChunk
exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */);
// When importing this way, the module is under the "default" member, not sure why (maybe
// esbuild-loader's doing).
exports.loadAce = () => import('ace-builds')
.then(async (m) => {
await Promise.all([
import('ace-builds/src-noconflict/ext-static_highlight'),
import('ace-builds/src-noconflict/mode-python'),
import('ace-builds/src-noconflict/theme-chrome'),
import('ace-builds/src-noconflict/theme-dracula'),
]);
return m.default;
});
exports.loadMomentTimezone = () => import('moment-timezone').then(m => m.default);
exports.loadPlotly = () => import('plotly.js-basic-dist' /* webpackChunkName: "plotly" */);
exports.loadSearch = () => import('app/client/ui2018/search' /* webpackChunkName: "search" */);

View File

@@ -0,0 +1,14 @@
/**
* We allow alphanumeric characters and certain common whitelisted characters (except at the start),
* plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be
* more precise about what exactly to allow).
*/
// eslint-disable-next-line no-control-regex
const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/;
/**
* Test name against various rules to check if it is a valid username.
*/
export function checkName(name: string): boolean {
return VALID_NAME_REGEXP.test(name);
}

View File

@@ -0,0 +1,21 @@
import moment from 'moment';
/**
* Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
* relative time to now - e.g. 'yesterday', '2 days ago'.
*/
export function getTimeFromNow(utcDateISO: string): string {
const time = moment.utc(utcDateISO);
const now = moment();
const diff = now.diff(time, 's');
if (diff < 0 && diff > -60) {
// If the time appears to be in the future, but less than a minute
// in the future, chalk it up to a difference in time
// synchronization and don't claim the resource will be changed in
// the future. For larger differences, just report them
// literally, there's a more serious problem or lack of
// synchronization.
return now.fromNow();
}
return time.fromNow();
}

View File

@@ -10,7 +10,7 @@ import {Notifier} from 'app/client/models/NotifyModel';
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {gristThemePrefs} from 'app/client/ui2018/theme';
import {AsyncCreate} from 'app/common/AsyncCreate';
import {ICustomWidget} from 'app/common/CustomWidget';
import {OrgUsageSummary} from 'app/common/DocUsage';
@@ -21,9 +21,7 @@ import {LocalPlugin} from 'app/common/plugin';
import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs';
import {isOwner, isOwnerOrEditor} from 'app/common/roles';
import {getTagManagerScript} from 'app/common/tagManager';
import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs,
ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes';
import {getDefaultThemePrefs, ThemePrefs, ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {ExtendedUser} from 'app/common/UserAPI';
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
@@ -118,7 +116,6 @@ export interface AppModel {
userPrefsObs: Observable<UserPrefs>;
themePrefs: Observable<ThemePrefs>;
currentTheme: Computed<Theme>;
/**
* Popups that user has seen.
*/
@@ -170,8 +167,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}) {
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}
) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
@@ -307,7 +305,6 @@ export class AppModelImpl extends Disposable implements AppModel {
defaultValue: getDefaultThemePrefs(),
checker: ThemePrefsChecker,
}) as Observable<ThemePrefs>;
public readonly currentTheme = this._getCurrentThemeObs();
public readonly dismissedPopups = getUserPrefObs(this.userPrefsObs, 'dismissedPopups',
{ defaultValue: [] }) as Observable<DismissedPopup[]>;
@@ -359,6 +356,11 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly orgError?: OrgError,
) {
super();
// Whenever theme preferences change, update the global `gristThemePrefs` observable; this triggers
// an automatic update to the global `gristThemeObs` computed observable.
this.autoDispose(subscribe(this.themePrefs, (_use, themePrefs) => gristThemePrefs.set(themePrefs)));
this._recordSignUpIfIsNewUser();
const state = urlState().state.get();
@@ -493,41 +495,14 @@ export class AppModelImpl extends Disposable implements AppModel {
dataLayer.push({event: 'new-sign-up'});
getUserPrefObs(this.userPrefsObs, 'recordSignUpEvent').set(undefined);
}
}
private _getCurrentThemeObs() {
return Computed.create(this, this.themePrefs, prefersDarkModeObs(),
(_use, themePrefs, prefersDarkMode) => {
let {appearance, syncWithOS} = themePrefs;
const urlParams = urlState().state.get().params;
if (urlParams?.themeAppearance) {
appearance = urlParams?.themeAppearance;
}
if (urlParams?.themeSyncWithOs !== undefined) {
syncWithOS = urlParams?.themeSyncWithOs;
}
if (syncWithOS) {
appearance = prefersDarkMode ? 'dark' : 'light';
}
let nameOrColors = themePrefs.colors[appearance];
if (urlParams?.themeName) {
nameOrColors = urlParams?.themeName;
}
let colors: ThemeColors;
if (typeof nameOrColors === 'string') {
colors = getThemeColors(nameOrColors);
} else {
colors = nameOrColors;
}
return {appearance, colors};
},
);
export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
if (!org) { return ''; }
if (user && user.anonymous && org.owner && org.owner.id === user.id) {
return "@Guest";
}
return getOrgName(org);
}
export function getHomeUrl(): string {
@@ -541,11 +516,3 @@ export function newUserAPIImpl(): UserAPIImpl {
fetch: hooks.fetch,
});
}
export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
if (!org) { return ''; }
if (user && user.anonymous && org.owner && org.owner.id === user.id) {
return "@Guest";
}
return getOrgName(org);
}

View File

@@ -21,7 +21,6 @@ export interface ICellItem {
cleanText: string; // Trimmed lowercase text for searching.
}
export class ColumnACIndexes {
private _columnCache = new ColumnCache<ACIndex<ICellItem>>(this._tableData);
@@ -33,22 +32,28 @@ export class ColumnACIndexes {
* getColACIndex() is called for the same column with the the same formatter.
*/
public getColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {
return this._columnCache.getValue(colId, () => this._buildColACIndex(colId, formatter));
return this._columnCache.getValue(colId, () => this.buildColACIndex(colId, formatter));
}
private _buildColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {
public buildColACIndex(
colId: string,
formatter: BaseFormatter,
filter?: (item: ICellItem) => boolean
): ACIndex<ICellItem> {
const rowIds = this._tableData.getRowIds();
const valColumn = this._tableData.getColValues(colId);
if (!valColumn) {
throw new UserError(`Invalid column ${this._tableData.tableId}.${colId}`);
}
const items: ICellItem[] = valColumn.map((val, i) => {
const rowId = rowIds[i];
const text = formatter.formatAny(val);
const cleanText = normalizeText(text);
return {rowId, text, cleanText};
});
items.sort(itemCompare);
const items: ICellItem[] = valColumn
.map((val, i) => {
const rowId = rowIds[i];
const text = formatter.formatAny(val);
const cleanText = normalizeText(text);
return {rowId, text, cleanText};
})
.filter((item) => filter?.(item) ?? true)
.sort(itemCompare);
return new ACIndexImpl(items);
}
}

View File

@@ -14,30 +14,11 @@ import * as roles from 'app/common/roles';
import {getGristConfig} from 'app/common/urlUtils';
import {Document, Organization, Workspace} from 'app/common/UserAPI';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import moment from 'moment';
import flatten = require('lodash/flatten');
import sortBy = require('lodash/sortBy');
const DELAY_BEFORE_SPINNER_MS = 500;
// Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
// relative time to now - e.g. 'yesterday', '2 days ago'.
export function getTimeFromNow(utcDateISO: string): string {
const time = moment.utc(utcDateISO);
const now = moment();
const diff = now.diff(time, 's');
if (diff < 0 && diff > -60) {
// If the time appears to be in the future, but less than a minute
// in the future, chalk it up to a difference in time
// synchronization and don't claim the resource will be changed in
// the future. For larger differences, just report them
// literally, there's a more serious problem or lack of
// synchronization.
return now.fromNow();
}
return time.fromNow();
}
export interface HomeModel {
// PageType value, one of the discriminated union values used by AppModel.
pageType: "home";
@@ -190,7 +171,6 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
localPlugins: _app.topAppModel.plugins,
untrustedContentOrigin: _app.topAppModel.getUntrustedContentOrigin()!,
clientScope,
theme: _app.currentTheme,
});
const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
this.importSources.set(importSources);

View File

@@ -2,12 +2,15 @@ import {ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRe
import {formatterForRec} from 'app/client/models/entities/ColumnRec';
import * as modelUtil from 'app/client/models/modelUtil';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import { HeaderStyle, Style } from 'app/client/models/Styles';
import {HeaderStyle, Style} from 'app/client/models/Styles';
import {ViewFieldConfig} from 'app/client/models/ViewFieldConfig';
import * as UserType from 'app/client/widgets/UserType';
import {DocumentSettings} from 'app/common/DocumentSettings';
import {DropdownCondition, DropdownConditionCompilationResult} from 'app/common/DropdownCondition';
import {compilePredicateFormula} from 'app/common/PredicateFormula';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {createParser} from 'app/common/ValueParser';
import {Computed} from 'grainjs';
import * as ko from 'knockout';
// Represents a page entry in the tree of pages.
@@ -106,6 +109,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
/** Label in FormView. By default FormView uses label, use this to override it. */
question: modelUtil.KoSaveableObservable<string|undefined>;
dropdownCondition: modelUtil.KoSaveableObservable<DropdownCondition|undefined>;
dropdownConditionCompiled: Computed<DropdownConditionCompilationResult|null>;
createValueParser(): (value: string) => any;
// Helper which adds/removes/updates field's displayCol to match the formula.
@@ -316,4 +322,21 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.disableModify = this.autoDispose(ko.pureComputed(() => this.column().disableModify()));
this.disableEditData = this.autoDispose(ko.pureComputed(() => this.column().disableEditData()));
this.dropdownCondition = this.widgetOptionsJson.prop('dropdownCondition');
this.dropdownConditionCompiled = Computed.create(this, use => {
const dropdownCondition = use(this.dropdownCondition);
if (!dropdownCondition?.parsed) { return null; }
try {
return {
kind: 'success',
result: compilePredicateFormula(JSON.parse(dropdownCondition.parsed), {
variant: 'dropdown-condition',
}),
};
} catch (e) {
return {kind: 'failure', error: e.message};
}
});
}

View File

@@ -1,4 +1,5 @@
import {detectCurrentLang, makeT} from 'app/client/lib/localization';
import {checkName} from 'app/client/lib/nameUtils';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import * as css from 'app/client/ui/AccountPageCss';
@@ -249,23 +250,6 @@ designed to ensure that you're the only person who can access your account, even
}
}
/**
* We allow alphanumeric characters and certain common whitelisted characters (except at the start),
* plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be
* more precise about what exactly to allow).
*/
// eslint-disable-next-line no-control-regex
const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/;
/**
* Test name against various rules to check if it is a valid username.
*/
export function checkName(name: string): boolean {
return VALID_NAME_REGEXP.test(name);
}
const cssWarnings = styled(css.warning, `
margin: -8px 0 0 110px;
`);

View File

@@ -14,6 +14,7 @@ import {setUpErrorHandling} from 'app/client/models/errors';
import {createAppUI} from 'app/client/ui/AppUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {attachTheme} from 'app/client/ui2018/theme';
import {BaseAPI} from 'app/common/BaseAPI';
import {CommDocError} from 'app/common/CommTypes';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
@@ -183,6 +184,7 @@ export class App extends DisposableWithEvents {
// Add the cssRootVars class to enable the variables in cssVars.
attachCssRootVars(this.topAppModel.productFlavor);
attachTheme();
addViewportTag();
this.autoDispose(createAppUI(this.topAppModel, this));
}

View File

@@ -17,7 +17,7 @@ import {pagePanels} from 'app/client/ui/PagePanels';
import {RightPanel} from 'app/client/ui/RightPanel';
import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
import {WelcomePage} from 'app/client/ui/WelcomePage';
import {attachTheme, testId} from 'app/client/ui2018/cssVars';
import {testId} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent, subscribe} from 'grainjs';
@@ -27,9 +27,7 @@ import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent
// TODO once #newui is gone, we don't need to worry about this being disposable.
// appObj is the App object from app/client/ui/App.ts
export function createAppUI(topAppModel: TopAppModel, appObj: App): IDisposable {
const content = dom.maybeOwned(topAppModel.appObs, (owner, appModel) => {
owner.autoDispose(attachTheme(appModel.currentTheme));
const content = dom.maybe(topAppModel.appObs, (appModel) => {
return [
createMainPage(appModel, appObj),
buildSnackbarDom(appModel.notifier, appModel),

View File

@@ -1,45 +1,112 @@
import * as ace from 'ace-builds';
import {Ace, loadAce} from 'app/client/lib/imports';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {BindableValue, Computed, dom, DomElementArg, Observable, styled, subscribeElem} from 'grainjs';
import {gristThemeObs} from 'app/client/ui2018/theme';
import {
BindableValue,
Disposable,
DomElementArg,
Observable,
styled,
subscribeElem,
} from 'grainjs';
// ace-builds also has a minified build (src-min-noconflict), but we don't
// use it since webpack already handles minification.
require('ace-builds/src-noconflict/ext-static_highlight');
require('ace-builds/src-noconflict/mode-python');
require('ace-builds/src-noconflict/theme-chrome');
require('ace-builds/src-noconflict/theme-dracula');
export interface ICodeOptions {
gristTheme: Computed<Theme>;
placeholder?: string;
interface BuildCodeHighlighterOptions {
maxLines?: number;
}
let _ace: Ace;
let _highlighter: any;
let _PythonMode: any;
let _aceDom: any;
let _chrome: any;
let _dracula: any;
let _mode: any;
async function fetchAceModules() {
return {
ace: _ace || (_ace = await loadAce()),
highlighter: _highlighter || (_highlighter = _ace.require('ace/ext/static_highlight')),
PythonMode: _PythonMode || (_PythonMode = _ace.require('ace/mode/python').Mode),
aceDom: _aceDom || (_aceDom = _ace.require('ace/lib/dom')),
chrome: _chrome || (_chrome = _ace.require('ace/theme/chrome')),
dracula: _dracula || (_dracula = _ace.require('ace/theme/dracula')),
mode: _mode || (_mode = new _PythonMode()),
};
}
/**
* Returns a function that accepts a string of text representing code and returns
* a highlighted version of it as an HTML string.
*
* This is useful for scenarios where highlighted code needs to be displayed outside of
* grainjs. For example, when using `marked`'s `highlight` option to highlight code
* blocks in a Markdown string.
*/
export async function buildCodeHighlighter(options: BuildCodeHighlighterOptions = {}) {
const {maxLines} = options;
const {highlighter, aceDom, chrome, dracula, mode} = await fetchAceModules();
return (code: string) => {
if (maxLines) {
// If requested, trim to maxLines, and add an ellipsis at the end.
// (Long lines are also truncated with an ellpsis via text-overflow style.)
const lines = code.split(/\n/);
if (lines.length > maxLines) {
code = lines.slice(0, maxLines).join("\n") + " \u2026"; // Ellipsis
}
}
let aceThemeName: 'chrome' | 'dracula';
let aceTheme: any;
if (gristThemeObs().get().appearance === 'dark') {
aceThemeName = 'dracula';
aceTheme = dracula;
} else {
aceThemeName = 'chrome';
aceTheme = chrome;
}
// Rendering highlighted code gives you back the HTML to insert into the DOM, as well
// as the CSS styles needed to apply the theme. The latter typically isn't included in
// the document until an Ace editor is opened, so we explicitly import it here to avoid
// leaving highlighted code blocks without a theme applied.
const {html, css} = highlighter.render(code, mode, aceTheme, 1, true);
aceDom.importCssString(css, `${aceThemeName}-highlighted-code`);
return html;
};
}
interface BuildHighlightedCodeOptions extends BuildCodeHighlighterOptions {
placeholder?: string;
}
/**
* Builds a block of highlighted `code`.
*
* Highlighting applies an appropriate Ace theme (Chrome or Dracula) based on
* the current Grist theme, and automatically re-applies it whenever the Grist
* theme changes.
*/
export function buildHighlightedCode(
code: BindableValue<string>, options: ICodeOptions, ...args: DomElementArg[]
owner: Disposable,
code: BindableValue<string>,
options: BuildHighlightedCodeOptions,
...args: DomElementArg[]
): HTMLElement {
const {gristTheme, placeholder, maxLines} = options;
const {enableCustomCss} = getGristConfig();
const {placeholder, maxLines} = options;
const codeText = Observable.create(owner, '');
const codeTheme = Observable.create(owner, gristThemeObs().get());
const highlighter = ace.require('ace/ext/static_highlight');
const PythonMode = ace.require('ace/mode/python').Mode;
const aceDom = ace.require('ace/lib/dom');
const chrome = ace.require('ace/theme/chrome');
const dracula = ace.require('ace/theme/dracula');
const mode = new PythonMode();
const codeText = Observable.create(null, '');
const codeTheme = Observable.create(null, gristTheme.get());
function updateHighlightedCode(elem: HTMLElement) {
async function updateHighlightedCode(elem: HTMLElement) {
let text = codeText.get();
if (!text) {
elem.textContent = placeholder || '';
return;
}
const {highlighter, aceDom, chrome, dracula, mode} = await fetchAceModules();
if (owner.isDisposed()) { return; }
if (maxLines) {
// If requested, trim to maxLines, and add an ellipsis at the end.
// (Long lines are also truncated with an ellpsis via text-overflow style.)
@@ -51,7 +118,7 @@ export function buildHighlightedCode(
let aceThemeName: 'chrome' | 'dracula';
let aceTheme: any;
if (codeTheme.get().appearance === 'dark' && !enableCustomCss) {
if (codeTheme.get().appearance === 'dark') {
aceThemeName = 'dracula';
aceTheme = dracula;
} else {
@@ -69,15 +136,13 @@ export function buildHighlightedCode(
}
return cssHighlightedCode(
dom.autoDispose(codeText),
dom.autoDispose(codeTheme),
elem => subscribeElem(elem, code, (newCodeText) => {
elem => subscribeElem(elem, code, async (newCodeText) => {
codeText.set(newCodeText);
updateHighlightedCode(elem);
await updateHighlightedCode(elem);
}),
elem => subscribeElem(elem, gristTheme, (newCodeTheme) => {
elem => subscribeElem(elem, gristThemeObs(), async (newCodeTheme) => {
codeTheme.set(newCodeTheme);
updateHighlightedCode(elem);
await updateHighlightedCode(elem);
}),
...args,
);
@@ -95,9 +160,7 @@ export const cssCodeBlock = styled('div', `
const cssHighlightedCode = styled(cssCodeBlock, `
position: relative;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid ${theme.highlightedCodeBorder};
border-radius: 3px;
min-height: 28px;
@@ -110,20 +173,6 @@ const cssHighlightedCode = styled(cssCodeBlock, `
& .ace_line {
overflow: hidden;
text-overflow: ellipsis;
}
`);
export const cssFieldFormula = styled(buildHighlightedCode, `
flex: auto;
cursor: pointer;
margin-top: 4px;
padding-left: 24px;
--icon-color: ${theme.accentIcon};
&-disabled-icon.formula_field_sidepane::before {
--icon-color: ${theme.iconDisabled};
}
&-disabled {
pointer-events: none;
white-space: nowrap;
}
`);

View File

@@ -1,9 +1,9 @@
import {makeT} from 'app/client/lib/localization';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {reportError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow} from 'app/client/models/HomeModel';
import {buildConfigContainer} from 'app/client/ui/RightPanel';
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';

View File

@@ -4,9 +4,10 @@
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
*/
import {loadUserManager} from 'app/client/lib/imports';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
import {HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {attachAddNewTip} from 'app/client/ui/AddNewTip';
import * as css from 'app/client/ui/DocMenuCss';

View File

@@ -2,7 +2,7 @@ import {makeT} from 'app/client/lib/localization';
import {GristDoc} from 'app/client/components/GristDoc';
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {cssBlockedCursor, cssFieldFormula, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
import {textButton} from 'app/client/ui2018/buttons';
@@ -13,7 +13,6 @@ import {IconName} from 'app/client/ui2018/IconList';
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
import {sanitizeIdent} from 'app/common/gutil';
import {Theme} from 'app/common/ThemePrefs';
import {CursorPos} from 'app/plugin/GristAPI';
import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder,
Observable, styled} from 'grainjs';
@@ -139,6 +138,8 @@ export function buildFormulaConfig(
// And close it dispose it when user opens up behavior menu.
let formulaField: HTMLElement|null = null;
const focusFormulaField = () => setTimeout(() => formulaField?.focus(), 0);
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
const clearState = () => bundleChanges(() => {
// For a detached editor, we may have already been disposed when user switched page.
@@ -242,7 +243,7 @@ export function buildFormulaConfig(
// Converts data column to formula column.
const convertDataColumnToFormulaOption = () => selectOption(
() => (maybeFormula.set(true), formulaField?.focus()),
() => (maybeFormula.set(true), focusFormulaField()),
t("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)
@@ -270,15 +271,15 @@ export function buildFormulaConfig(
const convertDataColumnToTriggerColumn = () => {
maybeTrigger.set(true);
// Open the formula editor.
formulaField?.focus();
focusFormulaField();
};
// 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());
const setFormula = () => { maybeFormula.set(true); focusFormulaField(); };
const setTrigger = () => { maybeTrigger.set(true); focusFormulaField(); };
// Actions on save formula. Those actions are using column that comes from FormulaEditor.
// Formula editor scope is broader then RightPanel, it can be disposed after RightPanel is closed,
@@ -325,16 +326,19 @@ export function buildFormulaConfig(
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
// Helper that will create different flavors for formula builder.
const formulaBuilder = (onSave: SaveHandler, canDetach?: boolean) => [
cssRow(formulaField = buildFormula(
origColumn,
buildEditor,
{
gristTheme: gristDoc.currentTheme,
disabled: disableOtherActions,
canDetach,
onSave,
onCancel: clearState,
})),
cssRow(
buildFormula(
origColumn,
buildEditor,
{
disabled: disableOtherActions,
canDetach,
onSave,
onCancel: clearState,
},
(el) => { formulaField = el; },
)
),
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
];
@@ -419,7 +423,6 @@ export function buildFormulaConfig(
}
interface BuildFormulaOptions {
gristTheme: Computed<Theme>;
disabled: Observable<boolean>;
canDetach?: boolean;
onSave?: SaveHandler;
@@ -429,10 +432,12 @@ interface BuildFormulaOptions {
function buildFormula(
column: ColumnRec,
buildEditor: BuildEditor,
options: BuildFormulaOptions
options: BuildFormulaOptions,
...args: DomElementArg[]
) {
const {gristTheme, disabled, canDetach = true, onSave, onCancel} = options;
return cssFieldFormula(column.formula, {gristTheme, maxLines: 2},
const {disabled, canDetach = true, onSave, onCancel} = options;
return dom.create(buildHighlightedCode, column.formula, {maxLines: 2},
dom.cls(cssFieldFormula.className),
dom.cls('formula_field_sidepane'),
cssFieldFormula.cls('-disabled', disabled),
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
@@ -447,24 +452,10 @@ function buildFormula(
onSave,
onCancel,
})),
...args,
);
}
export const cssFieldFormula = styled(buildHighlightedCode, `
flex: auto;
cursor: pointer;
margin-top: 4px;
padding-left: 24px;
--icon-color: ${theme.accentIcon};
&-disabled-icon.formula_field_sidepane::before {
--icon-color: ${theme.lightText};
}
&-disabled {
pointer-events: none;
}
`);
const cssToggleButton = styled(cssIconButton, `
margin-left: 8px;
background-color: ${theme.rightPanelToggleButtonDisabledBg};

View File

@@ -1,5 +1,6 @@
import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
@@ -39,7 +40,9 @@ export type Tooltip =
| 'uuid'
| 'lookups'
| 'formulaColumn'
| 'accessRulesTableWide';
| 'accessRulesTableWide'
| 'setChoiceDropdownCondition'
| 'setRefDropdownCondition';
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
@@ -125,7 +128,26 @@ see or edit which parts of your document.')
...args,
),
accessRulesTableWide: (...args: DomElementArg[]) => cssTooltipContent(
dom('div', t('These rules are applied after all column rules have been processed, if applicable.'))
dom('div', t('These rules are applied after all column rules have been processed, if applicable.')),
...args,
),
setChoiceDropdownCondition: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('Filter displayed dropdown values with a condition.')
),
dom('div', {style: 'margin-top: 8px;'}, t('Example: {{example}}', {
example: dom.create(buildHighlightedCode, 'choice not in $Categories', {}, {style: 'margin-top: 8px;'}),
})),
...args,
),
setRefDropdownCondition: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('Filter displayed dropdown values with a condition.')
),
dom('div', {style: 'margin-top: 8px;'}, t('Example: {{example}}', {
example: dom.create(buildHighlightedCode, 'choice.Role == "Manager"', {}, {style: 'margin-top: 8px;'}),
})),
...args,
),
};

View File

@@ -1,5 +1,6 @@
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow, HomeModel} from 'app/client/models/HomeModel';
import {HomeModel} from 'app/client/models/HomeModel';
import {makeDocOptionsMenu, makeRemovedDocOptionsMenu} from 'app/client/ui/DocMenu';
import {transientInput} from 'app/client/ui/transientInput';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';

View File

@@ -94,3 +94,18 @@ export const cssPinButton = styled('div', `
export const cssNumericSpinner = styled(numericSpinner, `
height: 28px;
`);
export const cssFieldFormula = styled('div', `
flex: auto;
cursor: pointer;
margin-top: 4px;
padding-left: 24px;
--icon-color: ${theme.accentIcon};
&-disabled-icon.formula_field_sidepane::before {
--icon-color: ${theme.iconDisabled};
}
&-disabled {
pointer-events: none;
}
`);

View File

@@ -2,7 +2,7 @@ import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import * as css from 'app/client/ui/AccountPageCss';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {prefersDarkModeObs} from 'app/client/ui2018/cssVars';
import {prefersColorSchemeDarkObs} from 'app/client/ui2018/theme';
import {select} from 'app/client/ui2018/menus';
import {ThemeAppearance} from 'app/common/ThemePrefs';
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
@@ -20,10 +20,10 @@ export class ThemeConfig extends Disposable {
private _appearance = Computed.create(this,
this._themePrefs,
this._syncWithOS,
prefersDarkModeObs(),
(_use, prefs, syncWithOS, prefersDarkMode) => {
prefersColorSchemeDarkObs(),
(_use, prefs, syncWithOS, prefersColorSchemeDark) => {
if (syncWithOS) {
return prefersDarkMode ? 'dark' : 'light';
return prefersColorSchemeDark ? 'dark' : 'light';
} else {
return prefs.appearance;
}

View File

@@ -4,7 +4,8 @@ import {AppModel, TopAppModelImpl, TopAppModelOptions} from 'app/client/models/A
import {reportError, setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars, attachTheme} from 'app/client/ui2018/cssVars';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {attachTheme} from 'app/client/ui2018/theme';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, DomContents} from 'grainjs';
@@ -16,22 +17,22 @@ const G = getBrowserGlobals('document', 'window');
*/
export function createAppPage(
buildAppPage: (appModel: AppModel) => DomContents,
modelOptions: TopAppModelOptions = {}) {
modelOptions: TopAppModelOptions = {}
) {
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions);
addViewportTag();
attachCssRootVars(topAppModel.productFlavor);
attachTheme();
setupLocale().catch(reportError);
// Add globals needed by test utils.
G.window.gristApp = {
testNumPendingApiRequests: () => BaseAPI.numPendingRequests(),
};
dom.update(document.body, dom.maybeOwned(topAppModel.appObs, (owner, appModel) => {
owner.autoDispose(attachTheme(appModel.currentTheme));
dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => {
return [
buildAppPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),

View File

@@ -4,7 +4,8 @@ import {reportError, setErrorNotifier, setUpErrorHandling} from 'app/client/mode
import {Notifier} from 'app/client/models/NotifyModel';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars, attachTheme, prefersColorSchemeThemeObs} from 'app/client/ui2018/cssVars';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {attachTheme} from 'app/client/ui2018/theme';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, DomContents} from 'grainjs';
@@ -21,6 +22,7 @@ export function createPage(buildPage: () => DomContents, options: {disableTheme?
addViewportTag();
attachCssRootVars('grist');
if (!disableTheme) { attachTheme(); }
setupLocale().catch(reportError);
// Add globals needed by test utils.
@@ -32,7 +34,6 @@ export function createPage(buildPage: () => DomContents, options: {disableTheme?
setErrorNotifier(notifier);
dom.update(document.body, () => [
disableTheme ? null : dom.autoDispose(attachTheme(prefersColorSchemeThemeObs())),
buildPage(),
buildSnackbarDom(notifier, null),
]);

View File

@@ -2,16 +2,17 @@
// keyboard. Dropdown features a search input and reoders the list of
// items to bring best matches at the top.
import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
import { theme, vars } from 'app/client/ui2018/cssVars';
import { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc,
normalizeText } from "app/client/lib/ACIndex";
import { menuDivider } from "app/client/ui2018/menus";
import { icon } from "app/client/ui2018/icons";
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
import { mergeWith } from "lodash";
import { getOptionFull, SimpleList } from "../lib/simpleList";
import { makeT } from 'app/client/lib/localization';
import { getOptionFull, SimpleList } from "app/client/lib/simpleList";
import { theme, vars } from 'app/client/ui2018/cssVars';
import { icon } from "app/client/ui2018/icons";
import { menuDivider } from "app/client/ui2018/menus";
import { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from "grainjs";
import mergeWith from "lodash/mergeWith";
import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel";
const t = makeT('searchDropdown');

View File

@@ -582,7 +582,7 @@ const cssInfoTooltipPopup = styled('div', `
display: flex;
flex-direction: column;
background-color: ${theme.popupBg};
max-width: 200px;
max-width: 240px;
margin: 4px;
padding: 0px;
`);

View File

@@ -6,16 +6,10 @@
* https://css-tricks.com/snippets/css/system-font-stack/
*
*/
import {createPausableObs, PausableObservable} from 'app/client/lib/pausableObs';
import {getStorage} from 'app/client/lib/storage';
import {urlState} from 'app/client/models/gristUrlState';
import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes';
import {Theme, ThemeAppearance} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs';
import {dom, DomElementMethod, makeTestId, Observable, styled, TestId} from 'grainjs';
import debounce = require('lodash/debounce');
import isEqual = require('lodash/isEqual');
import values = require('lodash/values');
const VAR_PREFIX = 'grist';
@@ -1021,51 +1015,6 @@ export function isScreenResizing(): Observable<boolean> {
return _isScreenResizingObs;
}
export function prefersDarkMode(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let _prefersDarkModeObs: PausableObservable<boolean>|undefined;
/**
* Returns a singleton observable for whether the user agent prefers dark mode.
*/
export function prefersDarkModeObs(): PausableObservable<boolean> {
if (!_prefersDarkModeObs) {
const query = window.matchMedia('(prefers-color-scheme: dark)');
const obs = createPausableObs<boolean>(null, query.matches);
query.addEventListener('change', event => obs.set(event.matches));
_prefersDarkModeObs = obs;
}
return _prefersDarkModeObs;
}
let _prefersColorSchemeThemeObs: Computed<Theme>|undefined;
/**
* Returns a singleton observable for the Grist theme matching the current
* user agent color scheme preference ("light" or "dark").
*/
export function prefersColorSchemeThemeObs(): Computed<Theme> {
if (!_prefersColorSchemeThemeObs) {
const obs = Computed.create(null, prefersDarkModeObs(), (_use, prefersDarkTheme) => {
if (prefersDarkTheme) {
return {
appearance: 'dark',
colors: getThemeColors('GristDark'),
} as const;
} else {
return {
appearance: 'light',
colors: getThemeColors('GristLight'),
} as const;
}
});
_prefersColorSchemeThemeObs = obs;
}
return _prefersColorSchemeThemeObs;
}
/**
* Attaches the global css properties to the document's root to make them available in the page.
*/
@@ -1081,96 +1030,6 @@ export function attachCssRootVars(productFlavor: ProductFlavor, varsOnly: boolea
document.body.classList.add(`interface-${interfaceStyle}`);
}
export function attachTheme(themeObs: Observable<Theme>) {
// Attach the current theme to the DOM.
attachCssThemeVars(themeObs.get());
// Whenever the theme changes, re-attach it to the DOM.
return themeObs.addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
attachCssThemeVars(newTheme);
});
}
/**
* Attaches theme-related css properties to the theme style element.
*/
function attachCssThemeVars({appearance, colors: themeColors}: Theme) {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return; }
// Prepare the custom properties needed for applying the theme.
const properties = Object.entries(themeColors)
.map(([name, value]) => `--grist-theme-${name}: ${value};`);
// Include properties for styling the scrollbar.
properties.push(...getCssScrollbarProperties(appearance));
// Include properties for picking an appropriate background image.
properties.push(...getCssThemeBackgroundProperties(appearance));
// Apply the properties to the theme style element.
getOrCreateStyleElement('grist-theme').textContent = `:root {
${properties.join('\n')}
}`;
// Make the browser aware of the color scheme.
document.documentElement.style.setProperty(`color-scheme`, appearance);
// Cache the appearance in local storage; this is currently used to apply a suitable
// background image that's shown while the application is loading.
getStorage().setItem('appearance', appearance);
}
/**
* Gets scrollbar-related css properties that are appropriate for the given `appearance`.
*
* Note: Browser support for customizing scrollbars is still a mixed bag; the bulk of customization
* is non-standard and unsupported by Firefox. If support matures, we could expose some of these in
* custom themes, but for now we'll just go with reasonable presets.
*/
function getCssScrollbarProperties(appearance: ThemeAppearance) {
return [
'--scroll-bar-fg: ' +
(appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'),
'--scroll-bar-hover-fg: ' +
(appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'),
'--scroll-bar-active-fg: ' +
(appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'),
'--scroll-bar-bg: ' +
(appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'),
];
}
/**
* Gets background-related css properties that are appropriate for the given `appearance`.
*
* Currently, this sets a property for showing a background image that's visible while a page
* is loading.
*/
function getCssThemeBackgroundProperties(appearance: ThemeAppearance) {
const value = appearance === 'dark'
? 'url("img/prismpattern.png")'
: 'url("img/gplaypattern.png")';
return [`--grist-theme-bg: ${value};`];
}
/**
* Gets or creates a style element in the head of the document with the given `id`.
*
* Useful for grouping CSS values such as theme custom properties without needing to
* pollute the document with in-line styles.
*/
function getOrCreateStyleElement(id: string) {
let style = document.head.querySelector(`#${id}`);
if (style) { return style; }
style = document.createElement('style');
style.setAttribute('id', id);
document.head.append(style);
return style;
}
// A dom method to hide element in print view
export function hideInPrintView(): DomElementMethod {
return cssHideInPrint.cls('');

191
app/client/ui2018/theme.ts Normal file
View File

@@ -0,0 +1,191 @@
import { createPausableObs, PausableObservable } from 'app/client/lib/pausableObs';
import { getStorage } from 'app/client/lib/storage';
import { urlState } from 'app/client/models/gristUrlState';
import { Theme, ThemeAppearance, ThemeColors, ThemePrefs } from 'app/common/ThemePrefs';
import { getThemeColors } from 'app/common/Themes';
import { getGristConfig } from 'app/common/urlUtils';
import { Computed, Observable } from 'grainjs';
import isEqual from 'lodash/isEqual';
const DEFAULT_LIGHT_THEME: Theme = {appearance: 'light', colors: getThemeColors('GristLight')};
const DEFAULT_DARK_THEME: Theme = {appearance: 'dark', colors: getThemeColors('GristDark')};
/**
* A singleton observable for the current user's Grist theme preferences.
*
* Set by `AppModel`, which populates it from `UserPrefs`.
*/
export const gristThemePrefs = Observable.create<ThemePrefs | null>(null, null);
/**
* Returns `true` if the user agent prefers a dark color scheme.
*/
export function prefersColorSchemeDark(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let _prefersColorSchemeDarkObs: PausableObservable<boolean> | undefined;
/**
* Returns a singleton observable for whether the user agent prefers a
* dark color scheme.
*/
export function prefersColorSchemeDarkObs(): PausableObservable<boolean> {
if (!_prefersColorSchemeDarkObs) {
const query = window.matchMedia('(prefers-color-scheme: dark)');
const obs = createPausableObs<boolean>(null, query.matches);
query.addEventListener('change', event => obs.set(event.matches));
_prefersColorSchemeDarkObs = obs;
}
return _prefersColorSchemeDarkObs;
}
let _gristThemeObs: Computed<Theme> | undefined;
/**
* A singleton observable for the current Grist theme.
*/
export function gristThemeObs() {
if (!_gristThemeObs) {
_gristThemeObs = Computed.create(null, (use) => {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return DEFAULT_LIGHT_THEME; }
// If a user's preference is known, return it.
const themePrefs = use(gristThemePrefs);
const userAgentPrefersDarkTheme = use(prefersColorSchemeDarkObs());
if (themePrefs) { return getThemeFromPrefs(themePrefs, userAgentPrefersDarkTheme); }
// Otherwise, fall back to the user agent's preference.
return userAgentPrefersDarkTheme ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME;
});
}
return _gristThemeObs;
}
/**
* Attaches the current theme's CSS variables to the document, and
* re-attaches them whenever the theme changes.
*/
export function attachTheme() {
// Custom CSS is incompatible with custom themes.
if (getGristConfig().enableCustomCss) { return; }
// Attach the current theme's variables to the DOM.
attachCssThemeVars(gristThemeObs().get());
// Whenever the theme changes, re-attach its variables to the DOM.
gristThemeObs().addListener((newTheme, oldTheme) => {
if (isEqual(newTheme, oldTheme)) { return; }
attachCssThemeVars(newTheme);
});
}
/**
* Returns the `Theme` from the given `themePrefs`.
*
* If theme query parameters are present (`themeName`, `themeAppearance`, `themeSyncWithOs`),
* they will take precedence over their respective values in `themePrefs`.
*/
function getThemeFromPrefs(themePrefs: ThemePrefs, userAgentPrefersDarkTheme: boolean): Theme {
let {appearance, syncWithOS} = themePrefs;
const urlParams = urlState().state.get().params;
if (urlParams?.themeAppearance) {
appearance = urlParams?.themeAppearance;
}
if (urlParams?.themeSyncWithOs !== undefined) {
syncWithOS = urlParams?.themeSyncWithOs;
}
if (syncWithOS) {
appearance = userAgentPrefersDarkTheme ? 'dark' : 'light';
}
let nameOrColors = themePrefs.colors[appearance];
if (urlParams?.themeName) {
nameOrColors = urlParams?.themeName;
}
let colors: ThemeColors;
if (typeof nameOrColors === 'string') {
colors = getThemeColors(nameOrColors);
} else {
colors = nameOrColors;
}
return {appearance, colors};
}
function attachCssThemeVars({appearance, colors: themeColors}: Theme) {
// Prepare the custom properties needed for applying the theme.
const properties = Object.entries(themeColors)
.map(([name, value]) => `--grist-theme-${name}: ${value};`);
// Include properties for styling the scrollbar.
properties.push(...getCssThemeScrollbarProperties(appearance));
// Include properties for picking an appropriate background image.
properties.push(...getCssThemeBackgroundProperties(appearance));
// Apply the properties to the theme style element.
getOrCreateStyleElement('grist-theme').textContent = `:root {
${properties.join('\n')}
}`;
// Make the browser aware of the color scheme.
document.documentElement.style.setProperty(`color-scheme`, appearance);
// Cache the appearance in local storage; this is currently used to apply a suitable
// background image that's shown while the application is loading.
getStorage().setItem('appearance', appearance);
}
/**
* Gets scrollbar-related css properties that are appropriate for the given `appearance`.
*
* Note: Browser support for customizing scrollbars is still a mixed bag; the bulk of customization
* is non-standard and unsupported by Firefox. If support matures, we could expose some of these in
* custom themes, but for now we'll just go with reasonable presets.
*/
function getCssThemeScrollbarProperties(appearance: ThemeAppearance) {
return [
'--scroll-bar-fg: ' +
(appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'),
'--scroll-bar-hover-fg: ' +
(appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'),
'--scroll-bar-active-fg: ' +
(appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'),
'--scroll-bar-bg: ' +
(appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'),
];
}
/**
* Gets background-related css properties that are appropriate for the given `appearance`.
*
* Currently, this sets a property for showing a background image that's visible while a page
* is loading.
*/
function getCssThemeBackgroundProperties(appearance: ThemeAppearance) {
const value = appearance === 'dark'
? 'url("img/prismpattern.png")'
: 'url("img/gplaypattern.png")';
return [`--grist-theme-bg: ${value};`];
}
/**
* Gets or creates a style element in the head of the document with the given `id`.
*
* Useful for grouping CSS values such as theme custom properties without needing to
* pollute the document with in-line styles.
*/
function getOrCreateStyleElement(id: string) {
let style = document.head.querySelector(`#${id}`);
if (style) { return style; }
style = document.createElement('style');
style.setAttribute('id', id);
document.head.append(style);
return style;
}

View File

@@ -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;

View File

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

View File

@@ -605,7 +605,6 @@ const cssButtonRow = styled('div', `
gap: 8px;
display: flex;
margin-top: 8px;
margin-bottom: 16px;
`);
const cssDeleteButton = styled('div', `

View File

@@ -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() {

View File

@@ -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) => {

View File

@@ -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)),
];
}

View File

@@ -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,
});
}

View File

@@ -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};

View File

@@ -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(),
];

View File

@@ -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;

View File

@@ -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;