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