commit
4b1b5b4193
@ -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;
|
||||
`);
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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) : []);
|
||||
}
|
@ -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
|
||||
}
|
@ -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)));
|
||||
}
|
@ -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
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue