Merge branch 'main' into translation-add-vue

pull/946/head
CamilleLegeron 1 month ago
commit 4b1b5b4193

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

@ -1,8 +1,8 @@
import { AppModel } from 'app/client/models/AppModel';
import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks';
import { createAppPage } from 'app/client/ui/createAppPage';
import { pagePanels } from 'app/client/ui/PagePanels';
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
@ -30,24 +30,14 @@ const cssResult = styled('div', `
*/
export class Boot extends Disposable {
// The back end will offer a set of probes (diagnostics) we
// can use. Probes have unique IDs.
public probes: Observable<BootProbeInfo[]>;
// Keep track of probe results we have received, by probe ID.
public results: Map<string, Observable<BootProbeResult>>;
// Keep track of probe requests we are making, by probe ID.
public requests: Map<string, BootProbe>;
private _checks: AdminChecks;
constructor(_appModel: AppModel) {
super();
// Setting title in constructor seems to be how we are doing this,
// based on other similar pages.
document.title = 'Booting Grist';
this.probes = Observable.create(this, []);
this.results = new Map();
this.requests = new Map();
this._checks = new AdminChecks(this);
}
/**
@ -55,20 +45,10 @@ export class Boot extends Disposable {
* side panel, just for convenience. Could be made a lot prettier.
*/
public buildDom() {
this._checks.fetchAvailableChecks().catch(e => reportError(e));
const config = getGristConfig();
const errMessage = config.errMessage;
if (!errMessage) {
// Probe tool URLs are relative to the current URL. Don't trust configuration,
// because it may be buggy if the user is here looking at the boot page
// to figure out some problem.
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname += '/probe';
fetch(url.href).then(async resp => {
const _probes = await resp.json();
this.probes.set(_probes.probes);
}).catch(e => reportError(e));
}
const rootNode = dom('div',
dom.domComputed(
use => {
@ -99,21 +79,10 @@ export class Boot extends Disposable {
return cssBody(cssResult(this.buildError()));
}
return cssBody([
...use(this.probes).map(probe => {
const {id} = probe;
let result = this.results.get(id);
if (!result) {
result = Observable.create(this, {});
this.results.set(id, result);
}
let request = this.requests.get(id);
if (!request) {
request = new BootProbe(id, this);
this.requests.set(id, request);
}
request.start();
...use(this._checks.probes).map(probe => {
const req = this._checks.requestCheck(probe);
return cssResult(
this.buildResult(probe, use(result), probeDetails[id]));
this.buildResult(req.probe, use(req.result), req.details));
}),
]);
}
@ -164,7 +133,7 @@ export class Boot extends Disposable {
for (const [key, val] of Object.entries(result.details)) {
out.push(dom(
'div',
key,
cssLabel(key),
dom('input', dom.prop('value', JSON.stringify(val)))));
}
}
@ -172,31 +141,6 @@ export class Boot extends Disposable {
}
}
/**
* Represents a single diagnostic.
*/
export class BootProbe {
constructor(public id: string, public boot: Boot) {
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname = url.pathname + '/probe/' + id;
fetch(url.href).then(async resp => {
const _probes: BootProbeResult = await resp.json();
const ob = boot.results.get(id);
if (ob) {
ob.set(_probes);
}
}).catch(e => console.error(e));
}
public start() {
let result = this.boot.results.get(this.id);
if (!result) {
result = Observable.create(this.boot, {});
this.boot.results.set(this.id, result);
}
}
}
/**
* Create a stripped down page to show boot information.
* Make sure the API isn't used since it may well be unreachable
@ -208,52 +152,9 @@ createAppPage(appModel => {
useApi: false,
});
/**
* Basic information about diagnostics is kept on the server,
* but it can be useful to show extra details and tips in the
* client.
*/
const probeDetails: Record<string, ProbeDetails> = {
'boot-page': {
info: `
This boot page should not be too easy to access. Either turn
it off when configuration is ok (by unsetting GRIST_BOOT_KEY)
or make GRIST_BOOT_KEY long and cryptographically secure.
`,
},
'health-check': {
info: `
Grist has a small built-in health check often used when running
it as a container.
`,
},
'host-header': {
info: `
Requests arriving to Grist should have an accurate Host
header. This is essential when GRIST_SERVE_SAME_ORIGIN
is set.
`,
},
'system-user': {
info: `
It is good practice not to run Grist as the root user.
`,
},
'reachable': {
info: `
The main page of Grist should be available.
`
},
};
/**
* Information about the probe.
*/
interface ProbeDetails {
info: string;
}
export const cssLabel = styled('div', `
display: inline-block;
min-width: 100px;
text-align: right;
padding-right: 5px;
`);

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

@ -48,6 +48,8 @@ export class DocComm extends Disposable implements ActiveDocAPI {
public getUsersForViewAs = this._wrapMethod("getUsersForViewAs");
public getAccessToken = this._wrapMethod("getAccessToken");
public getShare = this._wrapMethod("getShare");
public startTiming = this._wrapMethod("startTiming");
public stopTiming = this._wrapMethod("stopTiming");
public changeUrlIdEmitter = this.autoDispose(new Emitter());

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

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

@ -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" */);

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

@ -0,0 +1,27 @@
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
/**
* Given a unix timestamp (in milliseconds), gives a reader-friendly
* relative time to now - e.g. 'yesterday', '2 days ago'.
*/
export function getTimeFromNow(ms: number): string
export function getTimeFromNow(isoOrTimestamp: string|number): string {
const time = moment.utc(isoOrTimestamp);
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();
}

@ -0,0 +1,165 @@
import { BootProbeIds, BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, Observable, UseCBOwner } from 'grainjs';
/**
* Manage a collection of checks about the status of Grist, for
* presentation on the admin panel or the boot page.
*/
export class AdminChecks {
// The back end will offer a set of probes (diagnostics) we
// can use. Probes have unique IDs.
public probes: Observable<BootProbeInfo[]>;
// Keep track of probe requests we are making, by probe ID.
private _requests: Map<string, AdminCheckRunner>;
// Keep track of probe results we have received, by probe ID.
private _results: Map<string, Observable<BootProbeResult>>;
constructor(private _parent: Disposable) {
this.probes = Observable.create(_parent, []);
this._results = new Map();
this._requests = new Map();
}
/**
* Fetch a list of available checks from the server.
*/
public async fetchAvailableChecks() {
const config = getGristConfig();
const errMessage = config.errMessage;
if (!errMessage) {
// Probe tool URLs are relative to the current URL. Don't trust configuration,
// because it may be buggy if the user is here looking at the boot page
// to figure out some problem.
//
// We have been careful to make URLs available with appropriate
// middleware relative to both of the admin panel and the boot page.
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname += '/probe';
const resp = await fetch(url.href);
const _probes = await resp.json();
this.probes.set(_probes.probes);
}
}
/**
* Request the result of one of the available checks. Returns information
* about the check and a way to observe the result when it arrives.
*/
public requestCheck(probe: BootProbeInfo): AdminCheckRequest {
const {id} = probe;
let result = this._results.get(id);
if (!result) {
result = Observable.create(this._parent, {});
this._results.set(id, result);
}
let request = this._requests.get(id);
if (!request) {
request = new AdminCheckRunner(id, this._results, this._parent);
this._requests.set(id, request);
}
request.start();
return {
probe,
result,
details: probeDetails[id],
};
}
/**
* Request the result of a check, by its id.
*/
public requestCheckById(use: UseCBOwner, id: BootProbeIds): AdminCheckRequest|undefined {
const probe = use(this.probes).find(p => p.id === id);
if (!probe) { return; }
return this.requestCheck(probe);
}
}
/**
* Information about a check and a way to observe its result once available.
*/
export interface AdminCheckRequest {
probe: BootProbeInfo,
result: Observable<BootProbeResult>,
details: ProbeDetails,
}
/**
* Manage a single check.
*/
export class AdminCheckRunner {
constructor(public id: string, public results: Map<string, Observable<BootProbeResult>>,
public parent: Disposable) {
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname = url.pathname + '/probe/' + id;
fetch(url.href).then(async resp => {
const _probes: BootProbeResult = await resp.json();
const ob = results.get(id);
if (ob) {
ob.set(_probes);
}
}).catch(e => console.error(e));
}
public start() {
let result = this.results.get(this.id);
if (!result) {
result = Observable.create(this.parent, {});
this.results.set(this.id, result);
}
}
}
/**
* Basic information about diagnostics is kept on the server,
* but it can be useful to show extra details and tips in the
* client.
*/
const probeDetails: Record<string, ProbeDetails> = {
'boot-page': {
info: `
This boot page should not be too easy to access. Either turn
it off when configuration is ok (by unsetting GRIST_BOOT_KEY)
or make GRIST_BOOT_KEY long and cryptographically secure.
`,
},
'health-check': {
info: `
Grist has a small built-in health check often used when running
it as a container.
`,
},
'host-header': {
info: `
Requests arriving to Grist should have an accurate Host
header. This is essential when GRIST_SERVE_SAME_ORIGIN
is set.
`,
},
'system-user': {
info: `
It is good practice not to run Grist as the root user.
`,
},
'reachable': {
info: `
The main page of Grist should be available.
`
},
};
/**
* Information about the probe.
*/
export interface ProbeDetails {
info: string;
}

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

@ -1,9 +1,9 @@
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import * as version from 'app/common/version';
import {buildHomeBanners} from 'app/client/components/Banners';
import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
import {AdminChecks} from 'app/client/models/AdminChecks';
import {urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
@ -12,10 +12,20 @@ import {SupportGristPage} from 'app/client/ui/SupportGristPage';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {transition} from 'app/client/ui/transitions';
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
import {basicButton} from 'app/client/ui2018/buttons';
import {toggle} from 'app/client/ui2018/checkbox';
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs';
import {cssLink, makeLinks} from 'app/client/ui2018/links';
import {SandboxingBootProbeDetails} from 'app/common/BootProbe';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
import {naturalCompare} from 'app/common/SortFunc';
import {getGristConfig} from 'app/common/urlUtils';
import * as version from 'app/common/version';
import {Computed, Disposable, dom, DomContents, IDisposable,
IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
const t = makeT('AdminPanel');
@ -26,13 +36,19 @@ export function getAdminPanelName() {
export class AdminPanel extends Disposable {
private _supportGrist = SupportGristPage.create(this, this._appModel);
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
private _checks: AdminChecks;
constructor(private _appModel: AppModel) {
super();
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
this._checks = new AdminChecks(this);
}
public buildDom() {
this._checks.fetchAvailableChecks().catch(err => {
reportError(err);
});
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
@ -62,7 +78,7 @@ export class AdminPanel extends Disposable {
);
}
private _buildMainContent(owner: IDisposableOwner) {
private _buildMainContent(owner: MultiHolder) {
return cssPageContainer(
dom.cls('clipboard'),
{tabIndex: "-1"},
@ -83,6 +99,16 @@ export class AdminPanel extends Disposable {
expandedContent: this._supportGrist.buildSponsorshipSection(),
}),
),
cssSection(
cssSectionTitle(t('Security Settings')),
this._buildItem(owner, {
id: 'sandboxing',
name: t('Sandboxing'),
description: t('Sandbox settings for data engine'),
value: this._buildSandboxingDisplay(owner),
expandedContent: this._buildSandboxingNotice(),
}),
),
cssSection(
cssSectionTitle(t('Version')),
this._buildItem(owner, {
@ -91,11 +117,48 @@ export class AdminPanel extends Disposable {
description: t('Current version of Grist'),
value: cssValueLabel(`Version ${version.version}`),
}),
this._buildUpdates(owner),
),
testId('admin-panel'),
);
}
private _buildSandboxingDisplay(owner: IDisposableOwner) {
return dom.domComputed(
use => {
const req = this._checks.requestCheckById(use, 'sandboxing');
const result = req ? use(req.result) : undefined;
const success = result?.success;
const details = result?.details as SandboxingBootProbeDetails|undefined;
if (!details) {
return cssValueLabel(t('unknown'));
}
const flavor = details.flavor;
const configured = details.configured;
return cssValueLabel(
configured ?
(success ? cssHappy(t('OK') + `: ${flavor}`) :
cssError(t('Error') + `: ${flavor}`)) :
cssError(t('unconfigured')));
}
);
}
private _buildSandboxingNotice() {
return [
t('Grist allows for very powerful formulas, using Python. \
We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \
if your hardware supports it (most will), \
to run formulas in each document within a sandbox \
isolated from other documents and isolated from the network.'),
dom(
'div',
{style: 'margin-top: 8px'},
cssLink({href: commonUrls.helpSandboxing, target: '_blank'}, t('Learn more.'))
),
];
}
private _buildItem(owner: IDisposableOwner, options: {
id: string,
name: DomContents,
@ -138,18 +201,229 @@ export class AdminPanel extends Disposable {
);
}
}
private _buildUpdates(owner: MultiHolder) {
// We can be in those states:
enum State {
// Never checked before (no last version or last check time).
// Shows "No information available" [Check now]
NEVER,
// Did check previously, but it was a while ago, user should press the button to check.
// Shows "Last checked X days ago" [Check now]
STALE,
// In the middle of checking for updates.
CHECKING,
// Transient state, shown after Check now is clicked.
// Grist is up to date (state only shown after a successful check), or even upfront.
// Won't be shown after page is reloaded.
// Shows "Checking for updates..."
CURRENT,
// A newer version is available. Can be shown after reload if last
// version that was checked is newer than the current version.
// Shows "Newer version available" [version]
AVAILABLE,
// Error occurred during this check. If the error occurred during last check
// it is not stored.
// Shows "Error checking for updates" [Check now]
ERROR,
}
// Are updates enabled at all.
const defaultValue = {
onLoad: false,
lastCheckDate: null as number|null,
lastVersion: null as string|null,
};
const prop = <T extends keyof typeof defaultValue>(key: T) => {
const computed = Computed.create(owner, (use) => use(settings)[key]);
computed.onWrite((val) => settings.set({...settings.get(), [key]: val}));
return computed as Observable<typeof defaultValue[T]>;
};
const settings = owner.autoDispose(localStorageJsonObs('new-version-check', defaultValue));
const onLoad = prop('onLoad');
const latestVersion = prop('lastVersion');
const lastCheckDate = prop('lastCheckDate');
const comparison = Computed.create(owner, (use) => {
const versions = [version.version, use(latestVersion)];
if (!versions[1]) {
return null;
}
// Sort them in natural order, so that "1.10" comes after "1.9".
versions.sort(naturalCompare).reverse();
if (versions[0] === version.version) {
return 'old';
} else {
return 'new';
}
});
// Observable state of the updates check.
const state: Observable<State> = Observable.create(owner, State.NEVER);
// The background task that checks for updates, can be disposed (cancelled) when needed.
let backgroundTask: IDisposable|null = null;
// By default we link to the GitHub releases page, but the endpoint might say something different.
let releaseURL = 'https://github.com/gristlabs/grist-core/releases';
// All the events that might occur
const actions = {
checkForUpdates: async () => {
state.set(State.CHECKING);
latestVersion.set(null);
// We can be disabled, why the check is in progress.
const controller = new AbortController();
backgroundTask = {
dispose() {
if (controller.signal.aborted) { return; }
backgroundTask = null;
controller.abort();
}
};
owner.autoDispose(backgroundTask);
try {
const result = await this._installAPI.checkUpdates();
if (controller.signal.aborted) { return; }
actions.gotLatestVersion(result);
} catch(err) {
if (controller.signal.aborted) { return; }
state.set(State.ERROR);
reportError(err);
}
},
disableAutoCheck: () => {
backgroundTask?.dispose();
backgroundTask = null;
onLoad.set(false);
},
enableAutoCheck: () => {
onLoad.set(true);
if (state.get() !== State.CHECKING && state.get() !== State.AVAILABLE) {
actions.checkForUpdates().catch(reportError);
}
},
gotLatestVersion: (data: LatestVersion) => {
lastCheckDate.set(Date.now());
latestVersion.set(data.latestVersion);
releaseURL = data.updateURL || releaseURL;
const result = comparison.get();
switch (result) {
case 'old': state.set(State.CURRENT); break;
case 'new': state.set(State.AVAILABLE); break;
// This should not happen, but if it does, we should show the error.
default: state.set(State.ERROR); break;
}
}
};
const description = Computed.create(owner, (use) => {
switch (use(state)) {
case State.NEVER: return t('No information available');
case State.CHECKING: return '⌛ ' + t('Checking for updates...');
case State.CURRENT: return '✅ ' + t('Grist is up to date');
case State.AVAILABLE: return t('Newer version available');
case State.ERROR: return '❌ ' + t('Error checking for updates');
case State.STALE: {
const lastCheck = use(lastCheckDate);
return t('Last checked {{time}}', {time: lastCheck ? getTimeFromNow(lastCheck) : 'n/a'});
}
}
});
// Now trigger the initial state, by checking if we should auto-check.
if (onLoad.get()) {
actions.checkForUpdates().catch(reportError);
} else {
if (comparison.get() === 'new') {
state.set(State.AVAILABLE);
} else if (comparison.get() === 'old') {
state.set(State.STALE);
} else {
state.set(State.NEVER); // default one.
}
}
// Toggle component operates on a boolean observable, without a way to set the value. So
// create a controller for it to intercept the write and call the appropriate action.
const enabledController = Computed.create(owner, (use) => use(onLoad));
enabledController.onWrite((val) => {
if (val) {
actions.enableAutoCheck();
} else {
actions.disableAutoCheck();
}
});
const upperCheckNowVisible = Computed.create(owner, (use) => {
switch (use(state)) {
case State.CHECKING:
case State.CURRENT:
case State.AVAILABLE:
return false;
default:
return true;
}
});
return this._buildItem(owner, {
id: 'updates',
name: t('Updates'),
description: dom('span', testId('admin-panel-updates-message'), dom.text(description)),
value: cssValueButton(
dom.domComputed(use => {
if (use(state) === State.CHECKING) {
return null;
}
if (use(upperCheckNowVisible)) {
return basicButton(
t('Check now'),
dom.on('click', actions.checkForUpdates),
testId('admin-panel-updates-upper-check-now')
);
}
if (use(latestVersion)) {
return cssValueLabel(`Version ${use(latestVersion)}`, testId('admin-panel-updates-version'));
}
throw new Error('Invalid state');
})
),
expandedContent: cssColumns(
cssColumn(
cssColumn.cls('-left'),
dom('div', t('Grist releases are at '), makeLinks(releaseURL)),
dom.maybe(lastCheckDate, ms => dom('div',
dom('span', t('Last checked {{time}}', {time: getTimeFromNow(ms)})),
dom('span', ' '),
// Format date in local format.
cssGrayed(new Date(ms).toLocaleString()),
)),
dom('div', t('Auto-check when this page loads')),
),
cssColumn(
cssColumn.cls('-right'),
// `Check now` button, only shown when auto checks are enabled and we are not in the
// middle of checking. Otherwise the button is shown in the summary row, and there is
// no need to duplicate it.
dom.maybe(use => !use(upperCheckNowVisible), () => [
cssCheckNowButton(
t('Check now'),
testId('admin-panel-updates-lower-check-now'),
dom.on('click', actions.checkForUpdates),
dom.prop('disabled', use => use(state) === State.CHECKING),
),
]),
toggle(enabledController, testId('admin-panel-updates-auto-check')),
),
)
});
}
}
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
return dom('div.widget_switch',
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
dom.hide((use) => use(value) === null),
dom.cls('switch_on', (use) => use(value) || false),
dom.cls('switch_transition', true),
dom.on('click', () => value.set(!value.get())),
dom('div.switch_slider'),
dom('div.switch_circle'),
);
return toggle(value, dom.hide((use) => use(value) === null));
}
const cssPageContainer = styled('div', `
@ -215,7 +489,7 @@ const cssItemName = styled('div', `
font-weight: bold;
font-size: ${vars.largeFontSize};
&:first-child {
margin-left: 28px;
margin-left: 24px;
}
@media ${mediaSmall} {
& {
@ -267,4 +541,60 @@ const cssValueLabel = styled('div', `
color: ${theme.text};
border: 1px solid ${theme.inputBorder};
border-radius: ${vars.controlBorderRadius};
&-empty {
visibility: hidden;
content: " ";
}
`);
// A wrapper for the version details panel. Shows two columns.
// First grows as needed, second shrinks as needed and is aligned to the bottom.
const cssColumns = styled('div', `
display: flex;
align-items: flex-end;
& > div:first-child {
flex-grow: 1;
flex-shrink: 0;
}
& > div:last-child {
flex-shrink: 1;
}
`);
const cssColumn = styled('div', `
display: flex;
flex-direction: column;
gap: 8px;
flex-grow: 1;
flex-shrink: 1;
margin-block: 1px; /* otherwise toggle is squashed: TODO: -1px in toggle looks like a bug */
&-left {
align-items: flex-start;
}
&-right {
align-items: flex-end;
justify-content: flex-end;
}
`);
const cssValueButton = styled('div', `
height: 30px;
`);
const cssCheckNowButton = styled(basicButton, `
&-hidden {
visibility: hidden;
}
`);
const cssGrayed = styled('span', `
color: ${theme.lightText};
`);
export const cssError = styled('div', `
color: ${theme.errorText};
`);
export const cssHappy = styled('div', `
color: ${theme.controlFg};
`);

@ -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';
// 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;
import {gristThemeObs} from 'app/client/ui2018/theme';
import {
BindableValue,
Disposable,
DomElementArg,
Observable,
styled,
subscribeElem,
} from 'grainjs';
interface BuildCodeHighlighterOptions {
maxLines?: number;
}
export function buildHighlightedCode(
code: BindableValue<string>, options: ICodeOptions, ...args: DomElementArg[]
): HTMLElement {
const {gristTheme, placeholder, maxLines} = options;
const {enableCustomCss} = getGristConfig();
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;
};
}
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();
interface BuildHighlightedCodeOptions extends BuildCodeHighlighterOptions {
placeholder?: string;
}
const codeText = Observable.create(null, '');
const codeTheme = Observable.create(null, gristTheme.get());
/**
* 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(
owner: Disposable,
code: BindableValue<string>,
options: BuildHighlightedCodeOptions,
...args: DomElementArg[]
): HTMLElement {
const {placeholder, maxLines} = options;
const codeText = Observable.create(owner, '');
const codeTheme = Observable.create(owner, gristThemeObs().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,29 @@ 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;'}),
})),
dom('div',
cssLink({href: commonUrls.helpFilteringReferenceChoices, target: '_blank'}, t('Learn more.')),
),
...args,
),
};

@ -6,6 +6,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
import {IProgress} from 'app/client/models/NotifyModel';
import {openFilePicker} from 'app/client/ui/FileDialog';
import {byteString} from 'app/common/gutil';
import { AxiosProgressEvent } from 'axios';
import {Disposable} from 'grainjs';
/**
@ -39,9 +40,9 @@ export async function fileImport(
const timezone = await guessTimezone();
if (workspaceId === "unsaved") {
function onUploadProgress(ev: ProgressEvent) {
if (ev.lengthComputable) {
progress.setUploadProgress(ev.loaded / ev.total * 100); // percentage complete
function onUploadProgress(ev: AxiosProgressEvent) {
if (ev.event.lengthComputable) {
progress.setUploadProgress(ev.event.loaded / ev.event.total * 100); // percentage complete
}
}
return await app.api.importUnsavedDoc(files[0], {timezone, onUploadProgress});

@ -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 { 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";
import { mergeWith } from "lodash";
import { getOptionFull, SimpleList } from "../lib/simpleList";
import { makeT } from 'app/client/lib/localization';
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;
`);

@ -16,7 +16,7 @@
*/
import {testId, theme} from 'app/client/ui2018/cssVars';
import {Computed, dom, DomArg, DomContents, Observable, styled} from 'grainjs';
import {Computed, dom, DomArg, DomContents, DomElementArg, Observable, styled} from 'grainjs';
export const cssLabel = styled('label', `
position: relative;
@ -194,6 +194,19 @@ export const cssRadioCheckboxOptions = styled('div', `
gap: 10px;
`);
export function toggle(value: Observable<boolean|null>, ...domArgs: DomElementArg[]): DomContents {
return dom('div.widget_switch',
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
dom.hide((use) => use(value) === null),
dom.cls('switch_on', (use) => use(value) || false),
dom.cls('switch_transition', true),
dom.on('click', () => value.set(!value.get())),
dom('div.switch_slider'),
dom('div.switch_circle'),
...domArgs
);
}
// We need to reset top and left of ::before element, as it is wrongly set
// on the inline checkbox.
// To simulate radio button behavior, we will block user input after option is selected, because

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

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

@ -3,14 +3,15 @@ import {AVAILABLE_BITS_COLUMNS, AVAILABLE_BITS_TABLES, trimPermissions} from 'ap
import {ACLRulesReader} from 'app/common/ACLRulesReader';
import {AclRuleProblem} from 'app/common/ActiveDocAPI';
import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue, isNonNullish} from 'app/common/gutil';
import {CompiledPredicateFormula, ParsedPredicateFormula} from 'app/common/PredicateFormula';
import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes';
export type ILogger = Pick<Console, 'log'|'debug'|'info'|'warn'|'error'>;
const defaultMatchFunc: AclMatchFunc = () => true;
const defaultMatchFunc: CompiledPredicateFormula = () => true;
export const SPECIAL_RULES_TABLE_ID = '*SPECIAL';
@ -20,12 +21,12 @@ const DEFAULT_RULE_SET: RuleSet = {
colIds: '*',
body: [{
aclFormula: "user.Access in [EDITOR, OWNER]",
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
aclFormula: "user.Access in [VIEWER]",
matchFunc: (input) => ['viewers'].includes(String(input.user.Access)),
matchFunc: (input) => ['viewers'].includes(String(input.user!.Access)),
permissions: parsePermissions('+R-CUDS'),
permissionsText: '+R',
}, {
@ -48,7 +49,7 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
colIds: ['SchemaEdit'],
body: [{
aclFormula: "user.Access in [EDITOR, OWNER]",
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['editors', 'owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('+S'),
permissionsText: '+S',
}, {
@ -63,7 +64,7 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
colIds: ['AccessRules'],
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('+R'),
permissionsText: '+R',
}, {
@ -78,7 +79,7 @@ const SPECIAL_RULE_SETS: Record<string, RuleSet> = {
colIds: ['FullCopies'],
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('+R'),
permissionsText: '+R',
}, {
@ -102,7 +103,7 @@ const EMERGENCY_RULE_SET: RuleSet = {
colIds: '*',
body: [{
aclFormula: "user.Access in [OWNER]",
matchFunc: (input) => ['owners'].includes(String(input.user.Access)),
matchFunc: (input) => ['owners'].includes(String(input.user!.Access)),
permissions: parsePermissions('all'),
permissionsText: 'all',
}, {
@ -381,7 +382,7 @@ export class ACLRuleCollection {
export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
compile?: (parsed: ParsedPredicateFormula) => CompiledPredicateFormula;
// If true, add and modify access rules in some special ways.
// Specifically, call addHelperCols to add helper columns of restricted columns to rule sets,
// and use ACLShareRules to implement any special shares as access rules.

@ -3,7 +3,8 @@ import { getSetMapValue } from 'app/common/gutil';
import { SchemaTypes } from 'app/common/schema';
import { ShareOptions } from 'app/common/ShareOptions';
import { MetaRowRecord, MetaTableData } from 'app/common/TableData';
import { isEqual, sortBy } from 'lodash';
import isEqual from 'lodash/isEqual';
import sortBy from 'lodash/sortBy';
/**
* For special shares, we need to refer to resources that may not

@ -1,6 +1,6 @@
import {ActionGroup} from 'app/common/ActionGroup';
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
import {FormulaProperties} from 'app/common/GranularAccessClause';
import {PredicateFormulaProperties} from 'app/common/PredicateFormula';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
import {ParseOptions} from 'app/plugin/FileParserAPI';
@ -288,6 +288,48 @@ export interface RemoteShareInfo {
key: string;
}
/**
* Metrics gathered during formula calculations.
*/
export interface TimingInfo {
/**
* Total time spend evaluating a formula.
*/
total: number;
/**
* Number of times the formula was evaluated (for all rows).
*/
count: number;
average: number;
max: number;
}
/**
* Metrics attached to a particular column in a table. Contains also marks if they were gathered.
* Currently we only mark the `OrderError` exception (so when formula calculation was restarted due to
* order dependency).
*/
export interface FormulaTimingInfo extends TimingInfo {
tableId: string;
colId: string;
marks?: Array<TimingInfo & {name: string}>;
}
/*
* Status of timing info collection. Contains intermediate results if engine is not busy at the moment.
*/
export interface TimingStatus {
/**
* If true, timing info is being collected.
*/
status: boolean;
/**
* Will be undefined if we can't get the timing info (e.g. if the document is locked by other call).
* Otherwise, contains the intermediate results gathered so far.
*/
timing?: FormulaTimingInfo[];
}
export interface ActiveDocAPI {
/**
* Closes a document, and unsubscribes from its userAction events.
@ -379,7 +421,7 @@ export interface ActiveDocAPI {
* Find and return a list of auto-complete suggestions that start with `txt`, when editing a
* formula in table `tableId` and column `columnId`.
*/
autocomplete(txt: string, tableId: string, columnId: string, rowId: UIRowId): Promise<ISuggestionWithValue[]>;
autocomplete(txt: string, tableId: string, columnId: string, rowId: UIRowId | null): Promise<ISuggestionWithValue[]>;
/**
* Removes the current instance from the doc.
@ -425,7 +467,7 @@ export interface ActiveDocAPI {
/**
* Check if an ACL formula is valid. If not, will throw an error with an explanation.
*/
checkAclFormula(text: string): Promise<FormulaProperties>;
checkAclFormula(text: string): Promise<PredicateFormulaProperties>;
/**
* Get a token for out-of-band access to the document.
@ -449,5 +491,18 @@ export interface ActiveDocAPI {
*/
getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;
getShare(linkId: string): Promise<RemoteShareInfo>;
/**
* Get a share info associated with the document.
*/
getShare(linkId: string): Promise<RemoteShareInfo|null>;
/**
* Starts collecting timing information from formula evaluations.
*/
startTiming(): Promise<void>;
/**
* Stops collecting timing information and returns the collected data.
*/
stopTiming(): Promise<TimingInfo[]>;
}

@ -1,9 +1,11 @@
import { SandboxInfo } from 'app/common/SandboxInfo';
export type BootProbeIds =
'boot-page' |
'health-check' |
'reachable' |
'host-header' |
'sandboxing' |
'system-user'
;
@ -20,3 +22,4 @@ export interface BootProbeInfo {
name: string;
}
export type SandboxingBootProbeDetails = SandboxInfo;

@ -4,7 +4,7 @@ import {decodeObject} from "app/plugin/objtypes";
import moment, { Moment } from "moment-timezone";
import {extractInfoFromColType, isDateLikeType, isList, isListType, isNumberType} from "app/common/gristTypes";
import {isRelativeBound, relativeDateToUnixTimestamp} from "app/common/RelativeDates";
import {noop} from "lodash";
import noop from "lodash/noop";
export type ColumnFilterFunc = (value: CellValue) => boolean;

@ -0,0 +1,20 @@
import { CompiledPredicateFormula } from 'app/common/PredicateFormula';
export interface DropdownCondition {
text: string;
parsed: string;
}
export type DropdownConditionCompilationResult =
| DropdownConditionCompilationSuccess
| DropdownConditionCompilationFailure;
interface DropdownConditionCompilationSuccess {
kind: 'success';
result: CompiledPredicateFormula;
}
interface DropdownConditionCompilationFailure {
kind: 'failure';
error: string;
}

@ -1,7 +1,8 @@
import {PartialPermissionSet} from 'app/common/ACLPermissions';
import {CellValue, RowRecord} from 'app/common/DocActions';
import {CompiledPredicateFormula} from 'app/common/PredicateFormula';
import {Role} from 'app/common/roles';
import {MetaRowRecord} from 'app/common/TableData';
import {Role} from './roles';
export interface RuleSet {
tableId: '*' | string;
@ -18,7 +19,7 @@ export interface RulePart {
permissionsText: string; // The text version of PermissionSet, as stored.
// Compiled version of aclFormula.
matchFunc?: AclMatchFunc;
matchFunc?: CompiledPredicateFormula;
// Optional memo, currently extracted from comment in formula.
memo?: string;
@ -53,35 +54,6 @@ export interface UserInfo {
toJSON(): {[key: string]: any};
}
/**
* Input into the AclMatchFunc. Compiled formulas evaluate AclMatchInput to produce a boolean.
*/
export interface AclMatchInput {
user: UserInfo;
rec?: InfoView;
newRec?: InfoView;
docId?: string;
}
/**
* The actual boolean function that can evaluate a request. The result of compiling ParsedAclFormula.
*/
export type AclMatchFunc = (input: AclMatchInput) => boolean;
/**
* Representation of a parsed ACL formula.
*/
type PrimitiveCellValue = number|string|boolean|null;
export type ParsedAclFormula = [string, ...(ParsedAclFormula|PrimitiveCellValue)[]];
/**
* Observations about a formula.
*/
export interface FormulaProperties {
hasRecOrNewRec?: boolean;
usedColIds?: string[];
}
export interface UserAttributeRule {
origRecord?: RowRecord; // Original record used to create this UserAttributeRule.
name: string; // Should be unique among UserAttributeRules.
@ -89,45 +61,3 @@ export interface UserAttributeRule {
lookupColId: string; // Column in tableId in which to do the lookup.
charId: string; // Attribute to look up, possibly a path. E.g. 'Email' or 'office.city'.
}
/**
* Check some key facts about the formula.
*/
export function getFormulaProperties(formula: ParsedAclFormula) {
const result: FormulaProperties = {};
if (usesRec(formula)) { result.hasRecOrNewRec = true; }
const colIds = new Set<string>();
collectRecColIds(formula, colIds);
result.usedColIds = Array.from(colIds);
return result;
}
/**
* Check whether a formula mentions `rec` or `newRec`.
*/
export function usesRec(formula: ParsedAclFormula): boolean {
if (!Array.isArray(formula)) { throw new Error('expected a list'); }
if (isRecOrNewRec(formula)) {
return true;
}
return formula.some(el => {
if (!Array.isArray(el)) { return false; }
return usesRec(el);
});
}
function isRecOrNewRec(formula: ParsedAclFormula|PrimitiveCellValue): boolean {
return Array.isArray(formula) &&
formula[0] === 'Name' &&
(formula[1] === 'rec' || formula[1] === 'newRec');
}
function collectRecColIds(formula: ParsedAclFormula, colIds: Set<string>): void {
if (!Array.isArray(formula)) { throw new Error('expected a list'); }
if (formula[0] === 'Attr' && isRecOrNewRec(formula[1])) {
const colId = formula[2];
colIds.add(String(colId));
return;
}
formula.forEach(el => Array.isArray(el) && collectRecColIds(el, colIds));
}

@ -24,9 +24,38 @@ export interface PrefWithSource<T> {
export type PrefSource = 'environment-variable' | 'preferences';
/**
* JSON returned to the client (exported for tests).
*/
export interface LatestVersion {
/**
* Latest version of core component of the client.
*/
latestVersion: string;
/**
* If there were any critical updates after client's version. Undefined if
* we don't know client version or couldn't figure this out for some other reason.
*/
isCritical?: boolean;
/**
* Url where the client can download the latest version (if applicable)
*/
updateURL?: string;
/**
* When the latest version was updated (in ISO format).
*/
updatedAt?: string;
}
export interface InstallAPI {
getInstallPrefs(): Promise<InstallPrefsWithSources>;
updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void>;
/**
* Returns information about latest version of Grist
*/
checkUpdates(): Promise<LatestVersion>;
}
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
@ -45,6 +74,10 @@ export class InstallAPIImpl extends BaseAPI implements InstallAPI {
});
}
public checkUpdates(): Promise<LatestVersion> {
return this.requestJson(`${this._url}/api/install/updates`, {method: 'GET'});
}
private get _url(): string {
return addCurrentOrgToPath(this._homeUrl);
}

@ -0,0 +1,223 @@
/**
* Representation and compilation of predicate formulas.
*
* An example of a predicate formula is: "rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
* These formulas are parsed in Python into a tree with nodes of the form [NODE_TYPE, ...args].
* See sandbox/grist/predicate_formula.py for details.
*
* This module includes typings for the nodes, and the compilePredicateFormula() function that
* turns such trees into actual predicate functions.
*/
import {CellValue, RowRecord} from 'app/common/DocActions';
import {ErrorWithCode} from 'app/common/ErrorWithCode';
import {InfoView, UserInfo} from 'app/common/GranularAccessClause';
import {decodeObject} from 'app/plugin/objtypes';
import constant = require('lodash/constant');
/**
* Representation of a parsed predicate formula.
*/
export type PrimitiveCellValue = number|string|boolean|null;
export type ParsedPredicateFormula = [string, ...(ParsedPredicateFormula|PrimitiveCellValue)[]];
/**
* Inputs to a predicate formula function.
*/
export interface PredicateFormulaInput {
user?: UserInfo;
rec?: RowRecord|InfoView;
newRec?: InfoView;
docId?: string;
choice?: string|RowRecord|InfoView;
}
export class EmptyRecordView implements InfoView {
public get(_colId: string): CellValue { return null; }
public toJSON() { return {}; }
}
/**
* The result of compiling ParsedPredicateFormula.
*/
export type CompiledPredicateFormula = (input: PredicateFormulaInput) => boolean;
const GRIST_CONSTANTS: Record<string, string> = {
EDITOR: 'editors',
OWNER: 'owners',
VIEWER: 'viewers',
};
/**
* An intermediate predicate formula returned during compilation, which may return
* a non-boolean value.
*/
type IntermediatePredicateFormula = (input: PredicateFormulaInput) => any;
export interface CompilePredicateFormulaOptions {
/** Defaults to `'acl'`. */
variant?: 'acl'|'dropdown-condition';
}
/**
* Compiles a parsed predicate formula and returns it.
*/
export function compilePredicateFormula(
parsedPredicateFormula: ParsedPredicateFormula,
options: CompilePredicateFormulaOptions = {}
): CompiledPredicateFormula {
const {variant = 'acl'} = options;
function compileNode(node: ParsedPredicateFormula): IntermediatePredicateFormula {
const rawArgs = node.slice(1);
const args = rawArgs as ParsedPredicateFormula[];
switch (node[0]) {
case 'And': { const parts = args.map(compileNode); return (input) => parts.every(p => p(input)); }
case 'Or': { const parts = args.map(compileNode); return (input) => parts.some(p => p(input)); }
case 'Add': return compileAndCombine(args, ([a, b]) => a + b);
case 'Sub': return compileAndCombine(args, ([a, b]) => a - b);
case 'Mult': return compileAndCombine(args, ([a, b]) => a * b);
case 'Div': return compileAndCombine(args, ([a, b]) => a / b);
case 'Mod': return compileAndCombine(args, ([a, b]) => a % b);
case 'Not': return compileAndCombine(args, ([a]) => !a);
case 'Eq': return compileAndCombine(args, ([a, b]) => a === b);
case 'NotEq': return compileAndCombine(args, ([a, b]) => a !== b);
case 'Lt': return compileAndCombine(args, ([a, b]) => a < b);
case 'LtE': return compileAndCombine(args, ([a, b]) => a <= b);
case 'Gt': return compileAndCombine(args, ([a, b]) => a > b);
case 'GtE': return compileAndCombine(args, ([a, b]) => a >= b);
case 'Is': return compileAndCombine(args, ([a, b]) => a === b);
case 'IsNot': return compileAndCombine(args, ([a, b]) => a !== b);
case 'In': return compileAndCombine(args, ([a, b]) => Boolean(b?.includes(a)));
case 'NotIn': return compileAndCombine(args, ([a, b]) => !b?.includes(a));
case 'List': return compileAndCombine(args, (values) => values);
case 'Const': return constant(node[1] as CellValue);
case 'Name': {
const name = rawArgs[0] as keyof PredicateFormulaInput;
if (GRIST_CONSTANTS[name]) { return constant(GRIST_CONSTANTS[name]); }
let validNames: string[];
switch (variant) {
case 'acl': {
validNames = ['newRec', 'rec', 'user'];
break;
}
case 'dropdown-condition': {
validNames = ['rec', 'choice'];
break;
}
}
if (!validNames.includes(name)) { throw new Error(`Unknown variable '${name}'`); }
return (input) => input[name];
}
case 'Attr': {
const attrName = rawArgs[1] as string;
return compileAndCombine([args[0]], ([value]) => getAttr(value, attrName, args[0]));
}
case 'Comment': return compileNode(args[0]);
}
throw new Error(`Unknown node type '${node[0]}'`);
}
/**
* Helper for operators: compile a list of nodes, then when evaluating, evaluate them all and
* combine the array of results using the given combine() function.
*/
function compileAndCombine(
args: ParsedPredicateFormula[],
combine: (values: any[]) => any
): IntermediatePredicateFormula {
const compiled = args.map(compileNode);
return (input: PredicateFormulaInput) => combine(compiled.map(c => c(input)));
}
const compiledPredicateFormula = compileNode(parsedPredicateFormula);
return (input) => Boolean(compiledPredicateFormula(input));
}
function describeNode(node: ParsedPredicateFormula): string {
if (node[0] === 'Name') {
return node[1] as string;
} else if (node[0] === 'Attr') {
return describeNode(node[1] as ParsedPredicateFormula) + '.' + (node[2] as string);
} else {
return 'value';
}
}
function getAttr(value: any, attrName: string, valueNode: ParsedPredicateFormula): any {
if (value == null) {
if (valueNode[0] === 'Name' && (valueNode[1] === 'rec' || valueNode[1] === 'newRec')) {
// This code is recognized by GranularAccess to know when an ACL rule is row-specific.
throw new ErrorWithCode('NEED_ROW_DATA', `Missing row data '${valueNode[1]}'`);
}
throw new Error(`No value for '${describeNode(valueNode)}'`);
}
return typeof value.get === 'function'
? decodeObject(value.get(attrName)) // InfoView
: value[attrName];
}
/**
* Predicate formula properties.
*/
export interface PredicateFormulaProperties {
/**
* List of column ids that are referenced by either `$` or `rec.` notation.
*/
recColIds?: string[];
/**
* List of column ids that are referenced by `choice.` notation.
*
* Only applies to the `dropdown-condition` variant of predicate formulas,
* and only for Reference and Reference List columns.
*/
choiceColIds?: string[];
}
/**
* Returns properties about a predicate `formula`.
*
* Properties include the list of column ids referenced in the formula.
* Currently, this information is used for error validation; specifically, to
* report when invalid column ids are referenced in ACL formulas and dropdown
* conditions.
*/
export function getPredicateFormulaProperties(
formula: ParsedPredicateFormula
): PredicateFormulaProperties {
return {
recColIds: [...getRecColIds(formula)],
choiceColIds: [...getChoiceColIds(formula)],
};
}
function isRecOrNewRec(formula: ParsedPredicateFormula|PrimitiveCellValue): boolean {
return Array.isArray(formula) &&
formula[0] === 'Name' &&
(formula[1] === 'rec' || formula[1] === 'newRec');
}
function getRecColIds(formula: ParsedPredicateFormula): string[] {
return [...new Set(collectColIds(formula, isRecOrNewRec))];
}
function isChoice(formula: ParsedPredicateFormula|PrimitiveCellValue): boolean {
return Array.isArray(formula) && formula[0] === 'Name' && formula[1] === 'choice';
}
function getChoiceColIds(formula: ParsedPredicateFormula): string[] {
return [...new Set(collectColIds(formula, isChoice))];
}
function collectColIds(
formula: ParsedPredicateFormula,
isIdentifierWithColIds: (formula: ParsedPredicateFormula|PrimitiveCellValue) => boolean,
): string[] {
if (!Array.isArray(formula)) { throw new Error('expected a list'); }
if (formula[0] === 'Attr' && isIdentifierWithColIds(formula[1])) {
const colId = String(formula[2]);
return [colId];
}
return formula.flatMap(el => Array.isArray(el) ? collectColIds(el, isIdentifierWithColIds) : []);
}

@ -2,9 +2,12 @@
// time defined as a series of periods. Hence, starting from the current date, each one of the
// periods gets applied successively which eventually yields to the final date. Typical relative
import { isEqual, isNumber, isUndefined, omitBy } from "lodash";
import moment from "moment-timezone";
import getCurrentTime from "app/common/getCurrentTime";
import isEqual from "lodash/isEqual";
import isNumber from "lodash/isNumber";
import isUndefined from "lodash/isUndefined";
import omitBy from "lodash/omitBy";
import moment from "moment-timezone";
// Relative date uses one or two periods. When relative dates are defined by two periods, they are
// applied successively to the start date to resolve the target date. In practice in grist, as of

@ -0,0 +1,9 @@
export interface SandboxInfo {
flavor: string; // the type of sandbox in use (gvisor, unsandboxed, etc)
functional: boolean; // whether the sandbox can run code
effective: boolean; // whether the sandbox is actually giving protection
configured: boolean; // whether a sandbox type has been specified
// if sandbox fails to run, this records the last step that worked
lastSuccessfulStep: 'none' | 'create' | 'use' | 'all';
error?: string; // if sandbox fails, this stores an error
}

@ -22,6 +22,7 @@ import {
WebhookUpdate
} from 'app/common/Triggers';
import {addCurrentOrgToPath, getGristConfig} from 'app/common/urlUtils';
import { AxiosProgressEvent } from 'axios';
import omitBy from 'lodash/omitBy';
@ -405,7 +406,7 @@ export interface UserAPI {
importUnsavedDoc(material: UploadType, options?: {
filename?: string,
timezone?: string,
onUploadProgress?: (ev: ProgressEvent) => void,
onUploadProgress?: (ev: AxiosProgressEvent) => void,
}): Promise<string>;
deleteUser(userId: number, name: string): Promise<void>;
getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps.
@ -507,6 +508,16 @@ export interface DocAPI {
flushWebhook(webhookId: string): Promise<void>;
getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;
/**
* Check if the document is currently in timing mode.
*/
timing(): Promise<{status: boolean}>;
/**
* Starts recording timing information for the document. Throws exception if timing is already
* in progress or you don't have permission to start timing.
*/
startTiming(): Promise<void>;
stopTiming(): Promise<void>;
}
// Operations that are supported by a doc worker.
@ -816,7 +827,7 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
public async importUnsavedDoc(material: UploadType, options?: {
filename?: string,
timezone?: string,
onUploadProgress?: (ev: ProgressEvent) => void,
onUploadProgress?: (ev: AxiosProgressEvent) => void,
}): Promise<string> {
options = options || {};
const formData = this.newFormData();
@ -1121,6 +1132,18 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
});
}
public async timing(): Promise<{status: boolean}> {
return this.requestJson(`${this._url}/timing`);
}
public async startTiming(): Promise<void> {
await this.request(`${this._url}/timing/start`, {method: 'POST'});
}
public async stopTiming(): Promise<void> {
await this.request(`${this._url}/timing/stop`, {method: 'POST'});
}
private _getRecords(tableId: string, endpoint: 'data' | 'records', options?: GetRowsParams): Promise<any> {
const url = new URL(`${this._url}/tables/${tableId}/${endpoint}`);
if (options?.filters) {

@ -86,6 +86,8 @@ export const commonUrls = {
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown",
helpSandboxing: "https://support.getgrist.com/self-managed/#how-do-i-sandbox-documents",
freeCoachingCall: getFreeCoachingCallUrl(),
contactSupport: getContactSupportUrl(),
plans: "https://www.getgrist.com/pricing",
@ -104,6 +106,8 @@ export const commonUrls = {
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
githubGristCore: 'https://github.com/gristlabs/grist-core',
githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs',
versionCheck: 'https://api.getgrist.com/api/version',
};
/**

@ -70,6 +70,9 @@ export class DocApiForwarder {
app.use('/api/docs/:docId/webhooks', withDoc);
app.use('/api/docs/:docId/assistant', withDoc);
app.use('/api/docs/:docId/sql', withDoc);
app.use('/api/docs/:docId/timing', withDoc);
app.use('/api/docs/:docId/timing/start', withDoc);
app.use('/api/docs/:docId/timing/stop', withDoc);
app.use('/api/docs/:docId/forms/:vsId', withDoc);
app.use('^/api/docs$', withoutDoc);
}

@ -1,109 +0,0 @@
/**
* Representation and compilation of ACL formulas.
*
* An example of an ACL formula is: "rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
* These formulas are parsed in Python into a tree with nodes of the form [NODE_TYPE, ...args].
* See sandbox/grist/acl_formula.py for details.
*
* This modules includes typings for the nodes, and compileAclFormula() function that turns such a
* tree into an actual boolean function.
*/
import {CellValue} from 'app/common/DocActions';
import {ErrorWithCode} from 'app/common/ErrorWithCode';
import {AclMatchFunc, AclMatchInput, ParsedAclFormula} from 'app/common/GranularAccessClause';
import {decodeObject} from "app/plugin/objtypes";
import constant = require('lodash/constant');
const GRIST_CONSTANTS: Record<string, string> = {
EDITOR: 'editors',
OWNER: 'owners',
VIEWER: 'viewers',
};
/**
* Compile a parsed ACL formula into an actual function that can evaluate a request.
*/
export function compileAclFormula(parsedAclFormula: ParsedAclFormula): AclMatchFunc {
const compiled = _compileNode(parsedAclFormula);
return (input) => Boolean(compiled(input));
}
/**
* Type for intermediate functions, which may return values other than booleans.
*/
type AclEvalFunc = (input: AclMatchInput) => any;
/**
* Compile a single node of the parsed formula tree.
*/
function _compileNode(parsedAclFormula: ParsedAclFormula): AclEvalFunc {
const rawArgs = parsedAclFormula.slice(1);
const args = rawArgs as ParsedAclFormula[];
switch (parsedAclFormula[0]) {
case 'And': { const parts = args.map(_compileNode); return (input) => parts.every(p => p(input)); }
case 'Or': { const parts = args.map(_compileNode); return (input) => parts.some(p => p(input)); }
case 'Add': return _compileAndCombine(args, ([a, b]) => a + b);
case 'Sub': return _compileAndCombine(args, ([a, b]) => a - b);
case 'Mult': return _compileAndCombine(args, ([a, b]) => a * b);
case 'Div': return _compileAndCombine(args, ([a, b]) => a / b);
case 'Mod': return _compileAndCombine(args, ([a, b]) => a % b);
case 'Not': return _compileAndCombine(args, ([a]) => !a);
case 'Eq': return _compileAndCombine(args, ([a, b]) => a === b);
case 'NotEq': return _compileAndCombine(args, ([a, b]) => a !== b);
case 'Lt': return _compileAndCombine(args, ([a, b]) => a < b);
case 'LtE': return _compileAndCombine(args, ([a, b]) => a <= b);
case 'Gt': return _compileAndCombine(args, ([a, b]) => a > b);
case 'GtE': return _compileAndCombine(args, ([a, b]) => a >= b);
case 'Is': return _compileAndCombine(args, ([a, b]) => a === b);
case 'IsNot': return _compileAndCombine(args, ([a, b]) => a !== b);
case 'In': return _compileAndCombine(args, ([a, b]) => Boolean(b?.includes(a)));
case 'NotIn': return _compileAndCombine(args, ([a, b]) => !b?.includes(a));
case 'List': return _compileAndCombine(args, (values) => values);
case 'Const': return constant(parsedAclFormula[1] as CellValue);
case 'Name': {
const name = rawArgs[0] as keyof AclMatchInput;
if (GRIST_CONSTANTS[name]) { return constant(GRIST_CONSTANTS[name]); }
if (!['user', 'rec', 'newRec'].includes(name)) {
throw new Error(`Unknown variable '${name}'`);
}
return (input) => input[name];
}
case 'Attr': {
const attrName = rawArgs[1] as string;
return _compileAndCombine([args[0]], ([value]) => getAttr(value, attrName, args[0]));
}
case 'Comment': return _compileNode(args[0]);
}
throw new Error(`Unknown node type '${parsedAclFormula[0]}'`);
}
function describeNode(node: ParsedAclFormula): string {
if (node[0] === 'Name') {
return node[1] as string;
} else if (node[0] === 'Attr') {
return describeNode(node[1] as ParsedAclFormula) + '.' + (node[2] as string);
} else {
return 'value';
}
}
function getAttr(value: any, attrName: string, valueNode: ParsedAclFormula): any {
if (value == null) {
if (valueNode[0] === 'Name' && (valueNode[1] === 'rec' || valueNode[1] === 'newRec')) {
// This code is recognized by GranularAccess to know when a rule is row-specific.
throw new ErrorWithCode('NEED_ROW_DATA', `Missing row data '${valueNode[1]}'`);
}
throw new Error(`No value for '${describeNode(valueNode)}'`);
}
return (typeof value.get === 'function' ? decodeObject(value.get(attrName)) : // InfoView
value[attrName]);
}
/**
* Helper for operators: compile a list of nodes, then when evaluating, evaluate them all and
* combine the array of results using the given combine() function.
*/
function _compileAndCombine(args: ParsedAclFormula[], combine: (values: any[]) => any): AclEvalFunc {
const compiled = args.map(_compileNode);
return (input: AclMatchInput) => combine(compiled.map(c => c(input)));
}

@ -22,6 +22,7 @@ import {
ApplyUAResult,
DataSourceTransformed,
ForkResult,
FormulaTimingInfo,
ImportOptions,
ImportResult,
ISuggestionWithValue,
@ -63,12 +64,16 @@ import {
} from 'app/common/DocUsage';
import {normalizeEmail} from 'app/common/emails';
import {Product} from 'app/common/Features';
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
import {isHiddenCol} from 'app/common/gristTypes';
import {commonUrls, parseUrlId} from 'app/common/gristUrls';
import {byteString, countIf, retryOnce, safeJsonParse, timeoutReached} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer';
import {Interval} from 'app/common/Interval';
import {
compilePredicateFormula,
getPredicateFormulaProperties,
PredicateFormulaProperties,
} from 'app/common/PredicateFormula';
import * as roles from 'app/common/roles';
import {schema, SCHEMA_VERSION} from 'app/common/schema';
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
@ -83,7 +88,6 @@ import {Share} from 'app/gen-server/entity/Share';
import {RecordWithStringId} from 'app/plugin/DocApiTypes';
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
import {compileAclFormula} from 'app/server/lib/ACLFormula';
import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance';
import {AssistanceContext} from 'app/common/AssistancePrompts';
import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer';
@ -91,12 +95,14 @@ import {checksumFile} from 'app/server/lib/checksumFile';
import {Client} from 'app/server/lib/Client';
import {getMetaTables} from 'app/server/lib/DocApi';
import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager';
import {GristServer} from 'app/server/lib/GristServer';
import {ICreateActiveDocOptions} from 'app/server/lib/ICreate';
import {makeForkIds} from 'app/server/lib/idUtils';
import {GRIST_DOC_SQL, GRIST_DOC_WITH_TABLE1_SQL} from 'app/server/lib/initialDocSql';
import {ISandbox} from 'app/server/lib/ISandbox';
import log from 'app/server/lib/log';
import {LogMethods} from "app/server/lib/LogMethods";
import {ISandboxOptions} from 'app/server/lib/NSandbox';
import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox';
import {DocRequests} from 'app/server/lib/Requests';
import {shortDesc} from 'app/server/lib/shortDesc';
@ -220,6 +226,7 @@ export class ActiveDoc extends EventEmitter {
public docData: DocData|null = null;
// Used by DocApi to only allow one webhook-related endpoint to run at a time.
public readonly triggersLock: Mutex = new Mutex();
public isTimingOn = false;
protected _actionHistory: ActionHistory;
protected _docManager: DocManager;
@ -1287,7 +1294,11 @@ export class ActiveDoc extends EventEmitter {
}
public async autocomplete(
docSession: DocSession, txt: string, tableId: string, columnId: string, rowId: UIRowId
docSession: DocSession,
txt: string,
tableId: string,
columnId: string,
rowId: UIRowId | null
): Promise<ISuggestionWithValue[]> {
// Autocompletion can leak names of tables and columns.
if (!await this._granularAccess.canScanData(docSession)) { return []; }
@ -1366,6 +1377,7 @@ export class ActiveDoc extends EventEmitter {
*/
public async reloadDoc(docSession?: DocSession) {
this._log.debug(docSession || null, 'ActiveDoc.reloadDoc starting shutdown');
this._docManager.restoreTimingOn(this.docName, this.isTimingOn);
return this.shutdown();
}
@ -1476,16 +1488,16 @@ export class ActiveDoc extends EventEmitter {
/**
* Check if an ACL formula is valid. If not, will throw an error with an explanation.
*/
public async checkAclFormula(docSession: DocSession, text: string): Promise<FormulaProperties> {
public async checkAclFormula(docSession: DocSession, text: string): Promise<PredicateFormulaProperties> {
// Checks can leak names of tables and columns.
if (await this._granularAccess.hasNuancedAccess(docSession)) { return {}; }
await this.waitForInitialization();
try {
const parsedAclFormula = await this._pyCall('parse_acl_formula', text);
compileAclFormula(parsedAclFormula);
const parsedAclFormula = await this._pyCall('parse_predicate_formula', text);
compilePredicateFormula(parsedAclFormula);
// TODO We also need to check the validity of attributes, and of tables and columns
// mentioned in resources and userAttribute rules.
return getFormulaProperties(parsedAclFormula);
return getPredicateFormulaProperties(parsedAclFormula);
} catch (e) {
e.message = e.message?.replace('[Sandbox] ', '');
throw e;
@ -1870,6 +1882,40 @@ export class ActiveDoc extends EventEmitter {
return await this._getHomeDbManagerOrFail().getShareByLinkId(this.docName, linkId);
}
public async startTiming(): Promise<void> {
// Set the flag to indicate that timing is on.
this.isTimingOn = true;
try {
// Call the data engine to start timing.
await this._doStartTiming();
} catch (e) {
this.isTimingOn = false;
throw e;
}
// Mark self as in timing mode, in case we get reloaded.
this._docManager.restoreTimingOn(this.docName, true);
}
public async stopTiming(): Promise<FormulaTimingInfo[]> {
// First call the data engine to stop timing, and gather results.
const timingResults = await this._pyCall('stop_timing');
// Toggle the flag and clear the reminder.
this.isTimingOn = false;
this._docManager.restoreTimingOn(this.docName, false);
return timingResults;
}
public async getTimings(): Promise<FormulaTimingInfo[]|void> {
if (this._modificationLock.isLocked()) {
return;
}
return await this._pyCall('get_timings');
}
/**
* Loads an open document from DocStorage. Returns a list of the tables it contains.
*/
@ -2377,6 +2423,10 @@ export class ActiveDoc extends EventEmitter {
});
await this._pyCall('initialize', this._options?.docUrl);
if (this.isTimingOn) {
await this._doStartTiming();
}
// Calculations are not associated specifically with the user opening the document.
// TODO: be careful with which users can create formulas.
await this._applyUserActions(makeExceptionalDocSession('system'), [['Calculate']]);
@ -2686,7 +2736,9 @@ export class ActiveDoc extends EventEmitter {
}
private async _getEngine(): Promise<ISandbox> {
if (this._shuttingDown) { throw new Error('shutting down, data engine unavailable'); }
if (this._shuttingDown) {
throw new Error('shutting down, data engine unavailable');
}
if (this._dataEngine) { return this._dataEngine; }
this._dataEngine = this._isSnapshot ? this._makeNullEngine() : this._makeEngine();
@ -2714,11 +2766,9 @@ export class ActiveDoc extends EventEmitter {
}
}
}
return this._docManager.gristServer.create.NSandbox({
comment: this._docName,
logCalls: false,
logTimes: true,
logMeta: {docId: this._docName},
return createSandbox({
server: this._docManager.gristServer,
docId: this._docName,
preferredPythonVersion,
sandboxOptions: {
exports: {
@ -2830,6 +2880,10 @@ export class ActiveDoc extends EventEmitter {
return dbManager;
}
private _doStartTiming() {
return this._pyCall('start_timing');
}
}
// Helper to initialize a sandbox action bundle with no values.
@ -2897,3 +2951,23 @@ export async function getRealTableId(
export function sanitizeApplyUAOptions(options?: ApplyUAOptions): ApplyUAOptions {
return pick(options||{}, ['desc', 'otherId', 'linkId', 'parseStrings']);
}
/**
* Create a sandbox in its default initial state and with default logging.
*/
export function createSandbox(options: {
server: GristServer,
docId: string,
preferredPythonVersion: '2' | '3' | undefined,
sandboxOptions?: Partial<ISandboxOptions>,
}) {
const {docId, preferredPythonVersion, sandboxOptions, server} = options;
return server.create.NSandbox({
comment: docId,
logCalls: false,
logTimes: true,
logMeta: {docId},
preferredPythonVersion,
sandboxOptions,
});
}

@ -18,13 +18,16 @@ export class BootProbes {
public constructor(private _app: express.Application,
private _server: GristServer,
private _base: string) {
private _base: string,
private _middleware: express.Handler[] = []) {
this._addProbes();
}
public addEndpoints() {
// Return a list of available probes.
this._app.use(`${this._base}/probe$`, expressWrap(async (_, res) => {
this._app.use(`${this._base}/probe$`,
...this._middleware,
expressWrap(async (_, res) => {
res.json({
'probes': this._probes.map(probe => {
return { id: probe.id, name: probe.name };
@ -33,7 +36,9 @@ export class BootProbes {
}));
// Return result of running an individual probe.
this._app.use(`${this._base}/probe/:probeId`, expressWrap(async (req, res) => {
this._app.use(`${this._base}/probe/:probeId`,
...this._middleware,
expressWrap(async (req, res) => {
const probe = this._probeById.get(req.params.probeId);
if (!probe) {
throw new ApiError('unknown probe', 400);
@ -52,6 +57,7 @@ export class BootProbes {
this._probes.push(_userProbe);
this._probes.push(_bootProbe);
this._probes.push(_hostHeaderProbe);
this._probes.push(_sandboxingProbe);
this._probeById = new Map(this._probes.map(p => [p.id, p]));
}
}
@ -183,3 +189,16 @@ const _hostHeaderProbe: Probe = {
};
},
};
const _sandboxingProbe: Probe = {
id: 'sandboxing',
name: 'Sandboxing is working',
apply: async (server, req) => {
const details = server.getSandboxInfo();
return {
success: details?.configured && details?.functional,
details,
};
},
};

@ -1556,6 +1556,40 @@ export class DocWorkerApi {
});
})
);
// GET /api/docs/:docId/timings
// Checks if timing is on for the document.
this._app.get('/api/docs/:docId/timing', isOwner, withDoc(async (activeDoc, req, res) => {
if (!activeDoc.isTimingOn) {
res.json({status: 'disabled'});
} else {
const timing = await activeDoc.getTimings();
const status = timing ? 'active' : 'pending';
res.json({status, timing});
}
}));
// POST /api/docs/:docId/timings/start
// Start a timing for the document.
this._app.post('/api/docs/:docId/timing/start', isOwner, withDoc(async (activeDoc, req, res) => {
if (activeDoc.isTimingOn) {
res.status(400).json({error:`Timing already started for ${activeDoc.docName}`});
return;
}
// isTimingOn flag is switched synchronously.
await activeDoc.startTiming();
res.sendStatus(200);
}));
// POST /api/docs/:docId/timings/stop
// Stop a timing for the document.
this._app.post('/api/docs/:docId/timing/stop', isOwner, withDoc(async (activeDoc, req, res) => {
if (!activeDoc.isTimingOn) {
res.status(400).json({error:`Timing not started for ${activeDoc.docName}`});
return;
}
res.json(await activeDoc.stopTiming());
}));
}
/**

@ -43,7 +43,10 @@ export const DEFAULT_CACHE_TTL = 10000;
// How long to remember that a document has been explicitly set in a
// recovery mode.
export const RECOVERY_CACHE_TTL = 30000;
export const RECOVERY_CACHE_TTL = 30000; // 30 seconds
// How long to remember the timing mode of a document.
export const TIMING_ON_CACHE_TTL = 30000; // 30 seconds
/**
* DocManager keeps track of "active" Grist documents, i.e. those loaded
@ -56,6 +59,9 @@ export class DocManager extends EventEmitter {
// Remember recovery mode of documents.
private _inRecovery = new MapWithTTL<string, boolean>(RECOVERY_CACHE_TTL);
// Remember timing mode of documents, when document is recreated it is put in the same mode.
private _inTimingOn = new MapWithTTL<string, boolean>(TIMING_ON_CACHE_TTL);
constructor(
public readonly storageManager: IDocStorageManager,
public readonly pluginManager: PluginManager|null,
@ -69,6 +75,13 @@ export class DocManager extends EventEmitter {
this._inRecovery.set(docId, recovery);
}
/**
* Will restore timing on a document when it is reloaded.
*/
public restoreTimingOn(docId: string, timingOn: boolean) {
this._inTimingOn.set(docId, timingOn);
}
// attach a home database to the DocManager. During some tests, it
// is awkward to have this set up at the point of construction.
public testSetHomeDbManager(dbManager: HomeDBManager) {
@ -437,6 +450,10 @@ export class DocManager extends EventEmitter {
log.error('DocManager had problem shutting down storage: %s', err.message);
}
// Clear any timeouts we might have.
this._inRecovery.clear();
this._inTimingOn.clear();
// Clear the setInterval that the pidusage module sets up internally.
pidusage.clear();
}
@ -601,7 +618,10 @@ export class DocManager extends EventEmitter {
const doc = await this._getDoc(docSession, docName);
// Get URL for document for use with SELF_HYPERLINK().
const docUrls = doc && await this._getDocUrls(doc);
return new ActiveDoc(this, docName, {...docUrls, safeMode, doc});
const activeDoc = new ActiveDoc(this, docName, {...docUrls, safeMode, doc});
// Restore the timing mode of the document.
activeDoc.isTimingOn = this._inTimingOn.get(docName) || false;
return activeDoc;
}
/**

@ -132,6 +132,8 @@ export class DocWorker {
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
getAccessToken: activeDocMethod.bind(null, 'viewers', 'getAccessToken'),
getShare: activeDocMethod.bind(null, 'owners', 'getShare'),
startTiming: activeDocMethod.bind(null, 'owners', 'startTiming'),
stopTiming: activeDocMethod.bind(null, 'owners', 'stopTiming'),
});
}

@ -2,13 +2,14 @@ import {ApiError} from 'app/common/ApiError';
import {ICustomWidget} from 'app/common/CustomWidget';
import {delay} from 'app/common/delay';
import {DocCreationInfo} from 'app/common/DocListAPI';
import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
import {commonUrls, encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
sanitizePathTail} from 'app/common/gristUrls';
import {getOrgUrlInfo} from 'app/common/gristUrls';
import {isAffirmative, safeJsonParse} from 'app/common/gutil';
import {InstallProperties} from 'app/common/InstallAPI';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {SandboxInfo} from 'app/common/SandboxInfo';
import {tbind} from 'app/common/tbind';
import * as version from 'app/common/version';
import {ApiServer, getOrgFromRequest} from 'app/gen-server/ApiServer';
@ -23,6 +24,7 @@ import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
import {Usage} from 'app/gen-server/lib/Usage';
import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';
import {createSandbox} from 'app/server/lib/ActiveDoc';
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
import {appSettings} from 'app/server/lib/AppSettings';
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
@ -42,7 +44,7 @@ import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/
import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg';
import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth";
import {DocTemplate, GristLoginMiddleware, GristLoginSystem, GristServer,
RequestWithGrist} from 'app/server/lib/GristServer';
RequestWithGrist} from 'app/server/lib/GristServer';
import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions';
import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
import {IBilling} from 'app/server/lib/IBilling';
@ -181,6 +183,7 @@ export class FlexServer implements GristServer {
private _isReady: boolean = false;
private _probes: BootProbes;
private _updateManager: UpdateManager;
private _sandboxInfo: SandboxInfo;
constructor(public port: number, public name: string = 'flexServer',
public readonly options: FlexServerOptions = {}) {
@ -1367,6 +1370,47 @@ export class FlexServer implements GristServer {
}
}
public async checkSandbox() {
if (this._check('sandbox', 'doc')) { return; }
const flavor = process.env.GRIST_SANDBOX_FLAVOR || 'unknown';
const info = this._sandboxInfo = {
flavor,
configured: flavor !== 'unsandboxed',
functional: false,
effective: false,
sandboxed: false,
lastSuccessfulStep: 'none',
} as SandboxInfo;
try {
const sandbox = createSandbox({
server: this,
docId: 'test', // The id is just used in logging - no
// document is created or read at this level.
// In olden times, and in SaaS, Python 2 is supported. In modern
// times Python 2 is long since deprecated and defunct.
preferredPythonVersion: '3',
});
info.flavor = sandbox.getFlavor();
info.configured = info.flavor !== 'unsandboxed';
info.lastSuccessfulStep = 'create';
const result = await sandbox.pyCall('get_version');
if (typeof result !== 'number') {
throw new Error(`Expected a number: ${result}`);
}
info.lastSuccessfulStep = 'use';
await sandbox.shutdown();
info.lastSuccessfulStep = 'all';
info.functional = true;
info.effective = ![ 'skip', 'unsandboxed' ].includes(info.flavor);
} catch (e) {
info.error = String(e);
}
}
public getSandboxInfo(): SandboxInfo|undefined {
return this._sandboxInfo;
}
public disableExternalStorage() {
if (this.deps.has('doc')) {
throw new Error('disableExternalStorage called too late');
@ -1827,6 +1871,8 @@ export class FlexServer implements GristServer {
this.app.get('/admin', ...adminPageMiddleware, expressWrap(async (req, resp) => {
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
}));
const probes = new BootProbes(this.app, this, '/admin', adminPageMiddleware);
probes.addEndpoints();
// Restrict this endpoint to install admins too, for the same reason as the /admin page.
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
@ -1853,6 +1899,35 @@ export class FlexServer implements GristServer {
return resp.status(200).send();
}));
// GET api/checkUpdates
// Retrieves the latest version of the client from Grist SAAS endpoint.
this.app.get('/api/install/updates', adminPageMiddleware, expressWrap(async (req, res) => {
// Prepare data for the telemetry that endpoint might expect.
const installationId = (await this.getActivations().current()).id;
const deploymentType = this.getDeploymentType();
const currentVersion = version.version;
const response = await fetch(process.env.GRIST_TEST_VERSION_CHECK_URL || commonUrls.versionCheck, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
installationId,
deploymentType,
currentVersion,
}),
});
if (!response.ok) {
res.status(response.status);
if (response.headers.get('content-type')?.includes('application/json')) {
const data = await response.json();
res.json(data);
} else {
res.send(await response.text());
}
} else {
res.json(await response.json());
}
}));
}
// Get the HTML template sent for document pages.

@ -26,16 +26,15 @@ import { UserOverride } from 'app/common/DocListAPI';
import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage';
import { normalizeEmail } from 'app/common/emails';
import { ErrorWithCode } from 'app/common/ErrorWithCode';
import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
import { UserInfo } from 'app/common/GranularAccessClause';
import { InfoEditor, InfoView, UserInfo } from 'app/common/GranularAccessClause';
import * as gristTypes from 'app/common/gristTypes';
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
import { compilePredicateFormula, EmptyRecordView, PredicateFormulaInput } from 'app/common/PredicateFormula';
import { MetaRowRecord, SingleCell } from 'app/common/TableData';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { GristObjCode } from 'app/plugin/GristData';
import { compileAclFormula } from 'app/server/lib/ACLFormula';
import { DocClients } from 'app/server/lib/DocClients';
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare,
getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession';
@ -344,7 +343,7 @@ export class GranularAccess implements GranularAccessForBundle {
* Represent fields from the session in an input object for ACL rules.
* Just one field currently, "user".
*/
public async inputs(docSession: OptDocSession): Promise<AclMatchInput> {
public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
return {
user: await this._getUser(docSession),
docId: this._docId
@ -401,7 +400,7 @@ export class GranularAccess implements GranularAccessForBundle {
}
const rec = new RecordView(rows, 0);
if (!hasExceptionalAccess) {
const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec: rec};
const input: PredicateFormulaInput = {...await this.inputs(docSession), rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { fail(); }
@ -560,7 +559,7 @@ export class GranularAccess implements GranularAccessForBundle {
// Use the post-actions data to process the rules collection, and throw error if that fails.
const ruleCollection = new ACLRuleCollection();
await ruleCollection.update(tmpDocData, {log, compile: compileAclFormula});
await ruleCollection.update(tmpDocData, {log, compile: compilePredicateFormula});
if (ruleCollection.ruleError) {
throw new ApiError(ruleCollection.ruleError.message, 400);
}
@ -1664,7 +1663,7 @@ export class GranularAccess implements GranularAccessForBundle {
const rec = new RecordView(rowsRec, undefined);
const newRec = new RecordView(rowsNewRec, undefined);
const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec};
const input: PredicateFormulaInput = {...await this.inputs(docSession), rec, newRec};
const [, tableId, , colValues] = action;
let filteredColValues: ColValues | BulkColValues | undefined | null = null;
@ -1746,7 +1745,7 @@ export class GranularAccess implements GranularAccessForBundle {
colId?: string): Promise<number[]> {
const ruler = await this._getRuler(cursor);
const rec = new RecordView(data, undefined);
const input: AclMatchInput = {...await this.inputs(cursor.docSession), rec};
const input: PredicateFormulaInput = {...await this.inputs(cursor.docSession), rec};
const [, tableId, rowIds] = data;
const toRemove: number[] = [];
@ -2561,7 +2560,7 @@ export class GranularAccess implements GranularAccessForBundle {
}
}
const rec = rows ? new RecordView(rows, 0) : undefined;
const input: AclMatchInput = {...inputs, rec, newRec: rec};
const input: PredicateFormulaInput = {...inputs, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return false; }
@ -2635,7 +2634,11 @@ export class Ruler {
* Update granular access from DocData.
*/
public async update(docData: DocData) {
await this.ruleCollection.update(docData, {log, compile: compileAclFormula, enrichRulesForImplementation: true});
await this.ruleCollection.update(docData, {
log,
compile: compilePredicateFormula,
enrichRulesForImplementation: true,
});
// Also clear the per-docSession cache of rule evaluations.
this.clearCache();
@ -2652,7 +2655,7 @@ export class Ruler {
export interface RulerOwner {
getUser(docSession: OptDocSession): Promise<UserInfo>;
inputs(docSession: OptDocSession): Promise<AclMatchInput>;
inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
}
/**
@ -2762,11 +2765,6 @@ class RecordEditor implements InfoEditor {
}
}
class EmptyRecordView implements InfoView {
public get(colId: string): CellValue { return null; }
public toJSON() { return {}; }
}
/**
* Cache information about user attributes.
*/
@ -2840,7 +2838,7 @@ class CellAccessHelper {
private _tableAccess: Map<string, boolean> = new Map();
private _rowPermInfo: Map<string, Map<number, PermissionInfo>> = new Map();
private _rows: Map<string, TableDataAction> = new Map();
private _inputs!: AclMatchInput;
private _inputs!: PredicateFormulaInput;
constructor(
private _granular: GranularAccess,
@ -2864,7 +2862,7 @@ class CellAccessHelper {
for(const [idx, rowId] of rows[2].entries()) {
if (rowIds.has(rowId) === false) { continue; }
const rec = new RecordView(rows, idx);
const input: AclMatchInput = {...this._inputs, rec, newRec: rec};
const input: PredicateFormulaInput = {...this._inputs, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
if (!this._rowPermInfo.has(tableId)) {
this._rowPermInfo.set(tableId, new Map());

@ -1,6 +1,7 @@
import { ICustomWidget } from 'app/common/CustomWidget';
import { GristDeploymentType, GristLoadConfig } from 'app/common/gristUrls';
import { LocalPlugin } from 'app/common/plugin';
import { SandboxInfo } from 'app/common/SandboxInfo';
import { UserProfile } from 'app/common/UserAPI';
import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization';
@ -64,6 +65,7 @@ export interface GristServer {
servesPlugins(): boolean;
getBundledWidgets(): ICustomWidget[];
hasBoot(): boolean;
getSandboxInfo(): SandboxInfo|undefined;
}
export interface GristLoginSystem {
@ -154,6 +156,7 @@ export function createDummyGristServer(): GristServer {
getPlugins() { return []; },
getBundledWidgets() { return []; },
hasBoot() { return false; },
getSandboxInfo() { return undefined; },
};
}

@ -26,6 +26,7 @@ export interface ISandbox {
shutdown(): Promise<unknown>; // TODO: tighten up this type.
pyCall(funcName: string, ...varArgs: unknown[]): Promise<any>;
reportMemoryUsage(): Promise<void>;
getFlavor(): string;
}
export interface ISandboxCreator {

@ -132,7 +132,7 @@ export class MinIOExternalStorage implements ExternalStorage {
v.lastModified && (v as any).versionId &&
(options?.includeDeleteMarkers || !(v as any).isDeleteMarker))
.map(v => ({
lastModified: v.lastModified.toISOString(),
lastModified: v.lastModified!.toISOString(),
// Circumvent inconsistency of MinIO API with versionId by casting it to string
// PR to MinIO so we don't have to do that anymore:
// https://github.com/minio/minio-js/pull/1193

@ -230,6 +230,10 @@ export class NSandbox implements ISandbox {
log.rawDebug('Sandbox memory', {memory, ...this._logMeta});
}
public getFlavor() {
return this._logMeta.flavor;
}
/**
* Get ready to communicate with a sandbox process using stdin,
* stdout, and stderr.
@ -466,6 +470,10 @@ const spawners = {
gvisor, // Gvisor's runsc sandbox.
macSandboxExec, // Use "sandbox-exec" on Mac.
pyodide, // Run data engine using pyodide.
skip: unsandboxed, // Same as unsandboxed. Used to mean that the
// user deliberately doesn't want sandboxing.
// The "unsandboxed" setting is ambiguous in this
// respect.
};
function isFlavor(flavor: string): flavor is keyof typeof spawners {

@ -18,4 +18,8 @@ export class NullSandbox implements ISandbox {
public async reportMemoryUsage() {
throw new UnavailableSandboxMethodError('reportMemoryUsage is not available');
}
public getFlavor() {
return 'null';
}
}

@ -3,7 +3,8 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet,
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
toMixed } from 'app/common/ACLPermissions';
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
import { AclMatchInput, RuleSet, UserInfo } from 'app/common/GranularAccessClause';
import { RuleSet, UserInfo } from 'app/common/GranularAccessClause';
import { PredicateFormulaInput } from 'app/common/PredicateFormula';
import { getSetMapValue } from 'app/common/gutil';
import log from 'app/server/lib/log';
import { mapValues } from 'lodash';
@ -59,7 +60,7 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
// Construct a RuleInfo for a particular input, which is a combination of user and
// optionally a record.
constructor(protected _acls: ACLRuleCollection, protected _input: AclMatchInput) {}
constructor(protected _acls: ACLRuleCollection, protected _input: PredicateFormulaInput) {}
public getColumnAspect(tableId: string, colId: string): MixedT {
const ruleSet: RuleSet|undefined = this._acls.getColumnRuleSet(tableId, colId);
@ -80,7 +81,7 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
}
public getUser(): UserInfo {
return this._input.user;
return this._input.user!;
}
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
@ -205,7 +206,7 @@ export class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermission
* included, the result may include permission values like 'allowSome', 'denySome', or 'mixed' (for
* rules with memo).
*/
function evaluateRule(ruleSet: RuleSet, input: AclMatchInput): PartialPermissionSet {
function evaluateRule(ruleSet: RuleSet, input: PredicateFormulaInput): PartialPermissionSet {
let pset: PartialPermissionSet = emptyPermissionSet();
for (const rule of ruleSet.body) {
try {
@ -268,7 +269,7 @@ function evaluateRule(ruleSet: RuleSet, input: AclMatchInput): PartialPermission
* If a rule has a memo, and passes, add that memo for all permissions it denies.
* If a rule has a memo, and fails, add that memo for all permissions it allows.
*/
function extractMemos(ruleSet: RuleSet, input: AclMatchInput): MemoSet {
function extractMemos(ruleSet: RuleSet, input: PredicateFormulaInput): MemoSet {
const pset = emptyMemoSet();
for (const rule of ruleSet.body) {
try {

@ -1,14 +1,16 @@
import { ApiError } from "app/common/ApiError";
import { MapWithTTL } from "app/common/AsyncCreate";
import { GristDeploymentType } from "app/common/gristUrls";
import { LatestVersion } from 'app/common/InstallAPI';
import { naturalCompare } from "app/common/SortFunc";
import { RequestWithLogin } from "app/server/lib/Authorizer";
import { expressWrap } from 'app/server/lib/expressWrap';
import { GristServer } from "app/server/lib/GristServer";
import { optIntegerParam, optStringParam } from "app/server/lib/requestUtils";
import { rateLimit } from 'express-rate-limit';
import { AbortController, AbortSignal } from 'node-abort-controller';
import type * as express from "express";
import fetch from "node-fetch";
import {expressWrap} from 'app/server/lib/expressWrap';
// URL to show to the client where the new version for docker based deployments can be found.
@ -67,8 +69,18 @@ export class UpdateManager {
}
}
// Rate limit the requests to the version API, so that we don't get spammed.
// 30 requests per second, per IP. The requests are cached so, we should be fine, but make
// sure it doesn't get out of hand. On dev laptop I could go up to 600 requests per second.
// (30 was picked by hand, to not hit the limit during tests).
const limiter = rateLimit({
windowMs: 1000,
limit: 30,
legacyHeaders: true,
});
// Support both POST and GET requests.
this._app.use("/api/version", expressWrap(async (req, res) => {
this._app.use("/api/version", limiter, expressWrap(async (req, res) => {
// Get some telemetry from the body request.
const payload = (name: string) => req.body?.[name] ?? req.query[name];
@ -132,29 +144,6 @@ export class UpdateManager {
}
}
/**
* JSON returned to the client (exported for tests).
*/
export interface LatestVersion {
/**
* Latest version of core component of the client.
*/
latestVersion: string;
/**
* If there were any critical updates after client's version. Undefined if
* we don't know client version or couldn't figure this out for some other reason.
*/
isCritical?: boolean;
/**
* Url where the client can download the latest version (if applicable)
*/
updateURL?: string;
/**
* When the latest version was updated (in ISO format).
*/
updatedAt?: string;
}
type VersionChecker = (signal: AbortSignal) => Promise<LatestVersion>;

@ -179,6 +179,12 @@ export async function main(port: number, serverTypes: ServerType[],
server.checkOptionCombinations();
server.summary();
server.ready();
// Some tests have their timing perturbed by having this earlier
// TODO: update those tests.
if (includeDocs) {
await server.checkSandbox();
}
return server;
} catch(e) {
await server.close();

@ -74,6 +74,10 @@ module.exports = {
{ test: /\.js$/,
use: ["source-map-loader"],
enforce: "pre"
},
{
test: /\.css$/,
type: 'asset/resource'
}
]
},

@ -87,14 +87,14 @@
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"app-module-path": "2.2.0",
"catw": "1.0.1",
"chai": "4.2.0",
"chai-as-promised": "7.1.1",
"chance": "1.0.16",
"chokidar-cli": "3.0.0",
"esbuild-loader": "2.19.0",
"eslint": "8.18.0",
"http-proxy": "1.18.1",
"i18next-scanner": "4.1.0",
"i18next-scanner": "4.4.0",
"mocha": "10.2.0",
"mocha-webdriver": "0.3.2",
"moment-locales-webpack-plugin": "^1.2.0",
@ -106,7 +106,7 @@
"tmp-promise": "1.0.5",
"ts-interface-builder": "0.3.2",
"typescript": "4.7.4",
"webpack": "5.73.0",
"webpack": "5.91.0",
"webpack-cli": "4.10.0",
"why-is-node-running": "2.0.3"
},
@ -123,9 +123,9 @@
"accept-language-parser": "1.5.0",
"ace-builds": "1.23.3",
"async-mutex": "0.2.4",
"axios": "0.21.2",
"axios": "1.6.8",
"backbone": "1.3.3",
"bootstrap": "3.3.5",
"bootstrap": "3.4.1",
"bootstrap-datepicker": "1.9.0",
"bowser": "2.7.0",
"collect-js-deps": "^0.1.1",
@ -135,7 +135,7 @@
"connect-redis": "3.4.0",
"cookie": "0.5.0",
"cookie-parser": "1.4.3",
"csv": "4.0.0",
"csv": "6.3.8",
"currency-symbol-map": "5.1.0",
"diff-match-patch": "1.0.5",
"dompurify": "3.0.6",
@ -143,7 +143,8 @@
"engine.io": "^6.5.4",
"engine.io-client": "^6.5.3",
"exceljs": "4.2.1",
"express": "4.18.2",
"express": "4.19.2",
"express-rate-limit": "7.2.0",
"file-type": "16.5.4",
"fs-extra": "7.0.0",
"grain-rpc": "0.1.7",
@ -160,12 +161,12 @@
"js-yaml": "3.14.1",
"jsdom": "^23.0.0",
"jsesc": "3.0.2",
"jsonwebtoken": "8.3.0",
"jsonwebtoken": "9.0.2",
"knockout": "3.5.0",
"locale-currency": "0.0.2",
"lodash": "4.17.21",
"marked": "4.2.12",
"minio": "7.0.32",
"minio": "7.1.3",
"moment": "2.29.4",
"moment-timezone": "0.5.35",
"morgan": "1.9.1",
@ -184,13 +185,13 @@
"randomcolor": "0.5.3",
"redis": "3.1.1",
"redlock": "3.1.2",
"saml2-js": "2.0.5",
"saml2-js": "4.0.2",
"short-uuid": "3.1.1",
"slugify": "1.6.6",
"swagger-ui-dist": "5.11.0",
"tmp": "0.0.33",
"ts-interface-checker": "1.0.2",
"typeorm": "0.3.9",
"typeorm": "0.3.20",
"underscore": "1.12.1",
"uuid": "3.3.2",
"winston": "2.4.5",

@ -5,7 +5,8 @@
import json
import logging
from acl_formula import parse_acl_grist_entities, parse_acl_formula_json
from acl_formula import parse_acl_grist_entities
from predicate_formula import parse_predicate_formula_json
import action_obj
import textbuilder
@ -130,7 +131,7 @@ def prepare_acl_col_renames(docmodel, useractions, col_renames_dict):
replacer = textbuilder.Replacer(textbuilder.Text(formula), patches)
txt = replacer.get_text()
rule_updates.append((rule_rec, {'aclFormula': txt,
'aclFormulaParsed': parse_acl_formula_json(txt)}))
'aclFormulaParsed': parse_predicate_formula_json(txt)}))
def do_renames():
useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates)

@ -1,63 +1,19 @@
import ast
import io
import json
import tokenize
from collections import namedtuple
import asttokens
import six
from codebuilder import replace_dollar_attrs
from predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter
def parse_acl_formula(acl_formula):
def parse_acl_formulas(col_values):
"""
Parse an ACL formula expression into a parse tree that we can interpret in JS, e.g.
"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
The idea is to support enough to express ACL rules flexibly, but we don't need to support too
much, since rules should be reasonably simple.
The returned tree has the form [NODE_TYPE, arguments...], with these NODE_TYPEs supported:
And|Or ...values
Add|Sub|Mult|Div|Mod left, right
Not operand
Eq|NotEq|Lt|LtE|Gt|GtE left, right
Is|IsNot|In|NotIn left, right
List ...elements
Const value (number, string, bool)
Name name (string)
Attr node, attr_name
Comment node, comment
"""
if isinstance(acl_formula, six.binary_type):
acl_formula = acl_formula.decode('utf8')
try:
acl_formula = replace_dollar_attrs(acl_formula)
tree = ast.parse(acl_formula, mode='eval')
result = _TreeConverter().visit(tree)
for part in tokenize.generate_tokens(io.StringIO(acl_formula).readline):
if part[0] == tokenize.COMMENT and part[1].startswith('#'):
result = ['Comment', result, part[1][1:].strip()]
break
return result
except SyntaxError as err:
# In case of an error, include line and offset.
raise SyntaxError("%s on line %s col %s" % (err.args[0], err.lineno, err.offset))
def parse_acl_formula_json(acl_formula):
Populates `aclFormulaParsed` by parsing `aclFormula` for all `col_values`.
"""
As parse_acl_formula(), but stringifies the result, and converts empty string to empty string.
"""
return json.dumps(parse_acl_formula(acl_formula)) if acl_formula else ""
if 'aclFormula' not in col_values:
return
# Entities encountered in ACL formulas, which may get renamed.
# type : 'recCol'|'userAttr'|'userAttrCol',
# start_pos: number, # start position of the token in the code.
# name: string, # the name that may be updated by a rename.
# extra: string|None, # name of userAttr in case of userAttrCol; otherwise None.
NamedEntity = namedtuple('NamedEntity', ('type', 'start_pos', 'name', 'extra'))
col_values['aclFormulaParsed'] = [parse_predicate_formula_json(v)
for v
in col_values['aclFormula']]
def parse_acl_grist_entities(acl_formula):
"""
@ -72,69 +28,7 @@ def parse_acl_grist_entities(acl_formula):
except SyntaxError as err:
return []
named_constants = {
'True': True,
'False': False,
'None': None,
}
class _TreeConverter(ast.NodeVisitor):
# AST nodes are documented here: https://docs.python.org/2/library/ast.html#abstract-grammar
# pylint:disable=no-self-use
def visit_Expression(self, node):
return self.visit(node.body)
def visit_BoolOp(self, node):
return [node.op.__class__.__name__] + [self.visit(v) for v in node.values]
def visit_BinOp(self, node):
if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod)):
return self.generic_visit(node)
return [node.op.__class__.__name__, self.visit(node.left), self.visit(node.right)]
def visit_UnaryOp(self, node):
if not isinstance(node.op, (ast.Not)):
return self.generic_visit(node)
return [node.op.__class__.__name__, self.visit(node.operand)]
def visit_Compare(self, node):
# We don't try to support chained comparisons like "1 < 2 < 3" (though it wouldn't be hard).
if len(node.ops) != 1 or len(node.comparators) != 1:
raise ValueError("Can't use chained comparisons")
return [node.ops[0].__class__.__name__, self.visit(node.left), self.visit(node.comparators[0])]
def visit_Name(self, node):
if node.id in named_constants:
return ["Const", named_constants[node.id]]
return ["Name", node.id]
def visit_Constant(self, node):
return ["Const", node.value]
visit_NameConstant = visit_Constant
def visit_Attribute(self, node):
return ["Attr", self.visit(node.value), node.attr]
def visit_Num(self, node):
return ["Const", node.n]
def visit_Str(self, node):
return ["Const", node.s]
def visit_List(self, node):
return ["List"] + [self.visit(e) for e in node.elts]
def visit_Tuple(self, node):
return self.visit_List(node) # We don't distinguish tuples and lists
def generic_visit(self, node):
raise ValueError("Unsupported syntax at %s:%s" % (node.lineno, node.col_offset + 1))
class _EntityCollector(_TreeConverter):
class _EntityCollector(TreeConverter):
def __init__(self):
self.entities = [] # NamedEntity list

@ -524,11 +524,24 @@ class ReferenceListColumn(BaseReferenceColumn):
Accessing them yields RecordSets.
"""
def set(self, row_id, value):
if isinstance(value, six.string_types) and value.startswith(u'['):
if isinstance(value, six.string_types):
# This is second part of a "hack" we have to do when we rename tables. During
# the rename, we briefly change all Ref columns to Int columns (to lose the table
# part), and then back to Ref columns. The values during this change are stored
# as serialized strings, which we expect to understand when the column is back to
# being a Ref column. We can either end up with a list of ints, or a RecordList
# serialized as a string.
# TODO: explain why we need to do this and why we have chosen the Int column
try:
value = json.loads(value)
# If it's a string that looks like JSON, try to parse it as such.
if value.startswith('['):
value = json.loads(value)
else:
# Else try to parse it as a RecordList
value = objtypes.RecordList.from_repr(value)
except Exception:
pass
super(ReferenceListColumn, self).set(row_id, value)
def _update_references(self, row_id, old_list, new_list):

@ -0,0 +1,43 @@
import json
import logging
from predicate_formula import parse_predicate_formula_json
log = logging.getLogger(__name__)
def parse_dropdown_conditions(col_values):
"""
Parses any unparsed dropdown conditions in `col_values`.
"""
if 'widgetOptions' not in col_values:
return
col_values['widgetOptions'] = [parse_dropdown_condition(widget_options_json)
for widget_options_json
in col_values['widgetOptions']]
def parse_dropdown_condition(widget_options_json):
"""
Parses `dropdownCondition.text` in `widget_options_json` and stores the parsed
representation in `dropdownCondition.parsed`.
If `dropdownCondition.parsed` is already set, parsing is skipped (as an optimization).
Clients are responsible for including just `dropdownCondition.text` when creating new
(or updating existing) dropdown conditions.
Returns an updated copy of `widget_options_json` or the original widget_options_json
if parsing was skipped.
"""
try:
widget_options = json.loads(widget_options_json)
if 'dropdownCondition' not in widget_options:
return widget_options_json
dropdown_condition = widget_options['dropdownCondition']
if 'parsed' in dropdown_condition:
return widget_options_json
dropdown_condition['parsed'] = parse_predicate_formula_json(dropdown_condition['text'])
return json.dumps(widget_options)
except (TypeError, ValueError):
return widget_options_json

@ -36,6 +36,7 @@ import sandbox
import schema
from schema import RecalcWhen
import table as table_module
from timing import DummyTiming
from user import User # pylint:disable=wrong-import-order
import useractions
import column
@ -263,6 +264,8 @@ class Engine(object):
# make multiple different requests without needing to keep all the responses in memory.
self._cached_request_keys = set()
self._timing = DummyTiming()
@property
def autocomplete_context(self):
# See the comment on _autocomplete_context in __init__ above.
@ -969,51 +972,53 @@ class Engine(object):
assert not cycle
record = AttributeRecorder(record, "rec", record_attributes)
value = None
try:
if cycle:
raise depend.CircularRefError("Circular Reference")
if not col.is_formula():
value = col.get_cell_value(int(record), restore=True)
with FakeStdStreams():
result = col.method(record, table.user_table, value, self._user)
else:
with FakeStdStreams():
result = col.method(record, table.user_table)
if self._cell_required_error:
raise self._cell_required_error # pylint: disable=raising-bad-type
self.formula_tracer(col, record)
return result
except MemoryError:
# Don't try to wrap memory errors.
raise
except: # pylint: disable=bare-except
# Since col.method runs untrusted user code, we use a bare except to catch all
# exceptions (even those not derived from BaseException).
# Before storing the exception value, make sure there isn't an OrderError pending.
# If there is, we will raise it after undoing any side effects.
order_error = self._cell_required_error
# Otherwise, we use sys.exc_info to recover the raised exception object.
regular_error = sys.exc_info()[1] if not order_error else None
# It is possible for formula evaluation to have side-effects that produce DocActions (e.g.
# lookupOrAddDerived() creates those). If there is an error, undo any such side-effects.
self._undo_to_checkpoint(checkpoint)
# Now we can raise the order error, if there was one. Cell evaluation will be reordered
# in response.
if order_error:
self._cell_required_error = None
raise order_error # pylint: disable=raising-bad-type
self.formula_tracer(col, record)
include_details = (node not in self._is_node_exception_reported) if node else True
if not col.is_formula():
return objtypes.RaisedException(regular_error, include_details, user_input=value)
else:
return objtypes.RaisedException(regular_error, include_details)
with self._timing.measure(col.node):
try:
if cycle:
raise depend.CircularRefError("Circular Reference")
if not col.is_formula():
value = col.get_cell_value(int(record), restore=True)
with FakeStdStreams():
result = col.method(record, table.user_table, value, self._user)
else:
with FakeStdStreams():
result = col.method(record, table.user_table)
if self._cell_required_error:
raise self._cell_required_error # pylint: disable=raising-bad-type
self.formula_tracer(col, record)
return result
except MemoryError:
# Don't try to wrap memory errors.
raise
except: # pylint: disable=bare-except
# Since col.method runs untrusted user code, we use a bare except to catch all
# exceptions (even those not derived from BaseException).
# Before storing the exception value, make sure there isn't an OrderError pending.
# If there is, we will raise it after undoing any side effects.
order_error = self._cell_required_error
# Otherwise, we use sys.exc_info to recover the raised exception object.
regular_error = sys.exc_info()[1] if not order_error else None
# It is possible for formula evaluation to have side-effects that produce DocActions (e.g.
# lookupOrAddDerived() creates those). If there is an error, undo any such side-effects.
self._undo_to_checkpoint(checkpoint)
# Now we can raise the order error, if there was one. Cell evaluation will be reordered
# in response.
if order_error:
self._timing.mark("order_error")
self._cell_required_error = None
raise order_error # pylint: disable=raising-bad-type
self.formula_tracer(col, record)
include_details = (node not in self._is_node_exception_reported) if node else True
if not col.is_formula():
return objtypes.RaisedException(regular_error, include_details, user_input=value)
else:
return objtypes.RaisedException(regular_error, include_details)
def convert_action_values(self, action):
"""

@ -5,6 +5,8 @@ and starts the grist sandbox. See engine.py for the API documentation.
import os
import random
import sys
from timing import DummyTiming, Timing
sys.path.append('thirdparty')
# pylint: disable=wrong-import-position
@ -21,7 +23,7 @@ import migrations
import schema
import useractions
import objtypes
from acl_formula import parse_acl_formula
from predicate_formula import parse_predicate_formula
from sandbox import get_default_sandbox
from imports.register import register_import_parsers
@ -158,7 +160,21 @@ def run(sandbox):
def evaluate_formula(table_id, col_id, row_id):
return formula_prompt.evaluate_formula(eng, table_id, col_id, row_id)
export(parse_acl_formula)
@export
def start_timing():
eng._timing = Timing()
@export
def stop_timing():
stats = eng._timing.get()
eng._timing = DummyTiming()
return stats
@export
def get_timings():
return eng._timing.get()
export(parse_predicate_formula)
export(eng.load_empty)
export(eng.load_done)

@ -361,6 +361,20 @@ class CellError(Exception):
class RecordList(list):
"""
A static method to recreate a RecordList from the output of __repr__.
It only restores the row_ids. The group_by and sort_by attributes are not restored.
"""
@staticmethod
def from_repr(repr_str):
if not repr_str.startswith('RecordList(['):
raise ValueError("Invalid RecordList representation")
# This is a string representation of a RecordList, which we can parse.
# > RecordList([1,2,3], group_by=%r, sort_by=%r)
# Match only rows, as group_by and sort_by are not used and can be stale.
numbers = repr_str.split('[')[1].split(']')[0].split(',')
return RecordList([int(v) for v in numbers])
"""
Just like list but allows setting custom attributes, which we use for remembering _group_by and
_sort_by attributes when storing RecordSet as usertypes.ReferenceList type.
@ -375,7 +389,6 @@ class RecordList(list):
list.__repr__(self), self._group_by, self._sort_by)
# We don't currently have a good way to convert an incoming marshalled record to a proper Record
# object for an appropriate table. We don't expect incoming marshalled records at all, but if such
# a thing happens, we'll construct this RecordStub.

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save