(core) Support user variable in dropdown conditions

Summary:
Dropdown conditions can now reference a `user` variable, similar to the
one available in Access Rules.

Test Plan: Browser test.

Reviewers: jarek, paulfitz

Reviewed By: jarek, paulfitz

Differential Revision: https://phab.getgrist.com/D4255
This commit is contained in:
George Gevoian
2024-05-29 14:55:21 -07:00
parent 50077540e2
commit 72066bf0e4
27 changed files with 426 additions and 268 deletions

View File

@@ -1,4 +1,5 @@
import {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
@@ -7,7 +8,9 @@ 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 {UserInfo} from 'app/common/User';
import {Computed, Disposable, dom, Observable, styled} from 'grainjs';
import isPlainObject from 'lodash/isPlainObject';
const t = makeT('DropdownConditionConfig');
@@ -99,7 +102,7 @@ export class DropdownConditionConfig extends Disposable {
private _editorElement: HTMLElement;
constructor(private _field: ViewFieldRec) {
constructor(private _field: ViewFieldRec, private _gristDoc: GristDoc) {
super();
this.autoDispose(this._text.addListener(() => {
@@ -167,6 +170,10 @@ export class DropdownConditionConfig extends Disposable {
private _getAutocompleteSuggestions(): ISuggestionWithValue[] {
const variables = ['choice'];
const user = this._gristDoc.docPageModel.user.get();
if (user) {
variables.push(...getUserCompletions(user));
}
const refColumns = this._refColumns.get();
if (refColumns) {
variables.push('choice.id', ...refColumns.map(({colId}) => `choice.${colId.peek()}`));
@@ -176,7 +183,6 @@ export class DropdownConditionConfig extends Disposable {
...columns.map(({colId}) => `$${colId.peek()}`),
...columns.map(({colId}) => `rec.${colId.peek()}`),
);
const suggestions = [
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
'OWNER', 'EDITOR', 'VIEWER',
@@ -186,6 +192,20 @@ export class DropdownConditionConfig extends Disposable {
}
}
function getUserCompletions(user: UserInfo) {
return Object.entries(user).flatMap(([key, value]) => {
if (key === 'LinkKey') {
return 'user.LinkKey.';
} else if (isPlainObject(value)) {
return Object.keys(value as {[key: string]: any})
.filter(valueKey => valueKey !== 'manualSort')
.map(valueKey => `user.${key}.${valueKey}`);
} else {
return `user.${key}`;
}
});
}
const cssSetDropdownConditionRow = styled(cssRow, `
margin-top: 16px;
`);

View File

@@ -63,7 +63,7 @@ export class TypeTransform extends ColumnTransform {
if (use(this._isFormWidget)) {
return transformWidget.buildFormTransformConfigDom();
} else {
return transformWidget.buildTransformConfigDom();
return transformWidget.buildTransformConfigDom(this.gristDoc);
}
}),
dom.maybe(this._reviseTypeChange, () =>

View File

@@ -1,13 +1,13 @@
import {GristDoc} from 'app/client/components/GristDoc';
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 {EmptyRecordView} from 'app/common/RecordView';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {Disposable, dom, Observable} from 'grainjs';
@@ -26,9 +26,10 @@ export class ReferenceUtils extends Disposable {
public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text);
private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>;
private readonly _docData = this._gristDoc.docData;
private _dropdownConditionError = Observable.create<string | null>(this, null);
constructor(public readonly field: ViewFieldRec, private readonly _docData: DocData) {
constructor(public readonly field: ViewFieldRec, private readonly _gristDoc: GristDoc) {
super();
const colType = field.column().type();
@@ -38,7 +39,7 @@ export class ReferenceUtils extends Disposable {
}
this.refTableId = refTableId;
const tableData = _docData.getTable(refTableId);
const tableData = this._docData.getTable(refTableId);
if (!tableData) {
throw new Error("Invalid referenced table " + refTableId);
}
@@ -131,12 +132,13 @@ export class ReferenceUtils extends Disposable {
if (!table) { throw new Error(`Table ${tableId} not found`); }
const {result: predicate} = dropdownConditionCompiled;
const user = this._gristDoc.docPageModel.user.get() ?? undefined;
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});
return predicate({user, rec, choice});
};
}
}

View File

@@ -23,6 +23,7 @@ import {Features, mergedFeatures, Product} from 'app/common/Features';
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
import {getReconnectTimeout} from 'app/common/gutil';
import {canEdit, isOwner} from 'app/common/roles';
import {UserInfo} from 'app/common/User';
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
import {Holder, Observable, subscribe} from 'grainjs';
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
@@ -38,6 +39,7 @@ export interface DocInfo extends Document {
isPreFork: boolean;
isFork: boolean;
isRecoveryMode: boolean;
user: UserInfo|null;
userOverride: UserOverride|null;
isBareFork: boolean; // a document created without logging in, which is treated as a
// fork without an original.
@@ -78,6 +80,7 @@ export interface DocPageModel {
isPrefork: Observable<boolean>;
isFork: Observable<boolean>;
isRecoveryMode: Observable<boolean>;
user: Observable<UserInfo|null>;
userOverride: Observable<UserOverride|null>;
isBareFork: Observable<boolean>;
isSnapshot: Observable<boolean>;
@@ -134,6 +137,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
public readonly isRecoveryMode = Computed.create(this, this.currentDoc,
(use, doc) => doc ? doc.isRecoveryMode : false);
public readonly user = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.user : null);
public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null);
public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);
public readonly isSnapshot = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSnapshot : false);
@@ -265,8 +269,9 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
// TODO It would be bad if a new doc gets opened while this getDoc() is pending...
const newDoc = await getDoc(this._api, urlId);
const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode);
const user = this.currentDoc.get()?.user || null;
const userOverride = this.currentDoc.get()?.userOverride || null;
this.currentDoc.set({...buildDocInfo(newDoc, openMode), isRecoveryMode, userOverride});
this.currentDoc.set({...buildDocInfo(newDoc, openMode), isRecoveryMode, user, userOverride});
return newDoc;
}
@@ -407,11 +412,13 @@ It also disables formulas. [{{error}}]", {error: err.message})
linkParameters,
originalUrlId: options.originalUrlId,
});
if (openDocResponse.recoveryMode || openDocResponse.userOverride) {
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode);
doc.userOverride = openDocResponse.userOverride || null;
this.currentDoc.set({...doc});
const {user, recoveryMode, userOverride} = openDocResponse;
doc.user = user;
if (recoveryMode || userOverride) {
doc.isRecoveryMode = Boolean(recoveryMode);
doc.userOverride = userOverride || null;
}
this.currentDoc.set({...doc});
if (openDocResponse.docUsage) {
this.updateCurrentDocUsage(openDocResponse.docUsage);
}
@@ -520,6 +527,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
...doc,
isFork,
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
user: null, // ditto.
userOverride: null, // ditto.
isPreFork,
isBareFork,

View File

@@ -21,7 +21,7 @@ dispose.makeDisposable(AbstractWidget);
/**
* Builds the DOM showing configuration buttons and fields in the sidebar.
*/
AbstractWidget.prototype.buildConfigDom = function() {
AbstractWidget.prototype.buildConfigDom = function(_gristDoc) {
throw new Error("Not Implemented");
};
@@ -29,7 +29,7 @@ AbstractWidget.prototype.buildConfigDom = function() {
* Builds the transform prompt config DOM in the few cases where it is necessary.
* Child classes need not override this function if they do not require transform config options.
*/
AbstractWidget.prototype.buildTransformConfigDom = function() {
AbstractWidget.prototype.buildTransformConfigDom = function(_gristDoc) {
return null;
};

View File

@@ -129,7 +129,7 @@ ChoiceEditor.prototype.buildDropdownConditionFilter = function() {
return buildDropdownConditionFilter({
dropdownConditionCompiled: dropdownConditionCompiled.result,
docData: this.options.gristDoc.docData,
gristDoc: this.options.gristDoc,
tableId: this.options.field.tableId(),
rowId: this.options.rowId,
});

View File

@@ -1,10 +1,10 @@
import {createGroup} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
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';
@@ -12,7 +12,8 @@ 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 {CompiledPredicateFormula} from 'app/common/PredicateFormula';
import {EmptyRecordView} from 'app/common/RecordView';
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';
@@ -246,7 +247,7 @@ export class ChoiceListEditor extends NewBaseEditor {
return buildDropdownConditionFilter({
dropdownConditionCompiled: dropdownConditionCompiled.result,
docData: this.options.gristDoc.docData,
gristDoc: this.options.gristDoc,
tableId: this.options.field.tableId(),
rowId: this.options.rowId,
});
@@ -311,7 +312,7 @@ export class ChoiceListEditor extends NewBaseEditor {
export interface GetACFilterFuncParams {
dropdownConditionCompiled: CompiledPredicateFormula;
docData: DocData;
gristDoc: GristDoc;
tableId: string;
rowId: number;
}
@@ -319,12 +320,13 @@ export interface GetACFilterFuncParams {
export function buildDropdownConditionFilter(
params: GetACFilterFuncParams
): (item: ChoiceItem) => boolean {
const {dropdownConditionCompiled, docData, tableId, rowId} = params;
const table = docData.getTable(tableId);
const {dropdownConditionCompiled, gristDoc, tableId, rowId} = params;
const table = gristDoc.docData.getTable(tableId);
if (!table) { throw new Error(`Table ${tableId} not found`); }
const user = gristDoc.docPageModel.user.get() ?? undefined;
const rec = table.getRecord(rowId) || new EmptyRecordView();
return (item: ChoiceItem) => dropdownConditionCompiled({rec, choice: item.label});
return (item: ChoiceItem) => dropdownConditionCompiled({user, rec, choice: item.label});
}
const cssCellEditor = styled('div', `

View File

@@ -4,6 +4,7 @@ import {
FormSelectConfig,
} from 'app/client/components/Forms/FormConfig';
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
@@ -79,17 +80,17 @@ export class ChoiceTextBox extends NTextBox {
);
}
public buildConfigDom() {
public buildConfigDom(gristDoc: GristDoc) {
return [
super.buildConfigDom(),
super.buildConfigDom(gristDoc),
this.buildChoicesConfigDom(),
dom.create(DropdownConditionConfig, this.field),
dom.create(DropdownConditionConfig, this.field, gristDoc),
];
}
public buildTransformConfigDom() {
public buildTransformConfigDom(gristDoc: GristDoc) {
return [
super.buildConfigDom(),
super.buildConfigDom(gristDoc),
this.buildChoicesConfigDom(),
];
}

View File

@@ -54,7 +54,7 @@ _.extend(DateTimeTextBox.prototype, DateTextBox.prototype);
* Builds the config dom for the DateTime TextBox. If isTransformConfig is true,
* builds only the necessary dom for the transform config menu.
*/
DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
DateTimeTextBox.prototype.buildConfigDom = function(_gristDoc, isTransformConfig) {
const disabled = ko.pureComputed(() => {
return this.field.config.options.disabled('timeFormat')() || this.field.column().disableEditData();
});
@@ -92,8 +92,8 @@ DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
);
};
DateTimeTextBox.prototype.buildTransformConfigDom = function() {
return this.buildConfigDom(true);
DateTimeTextBox.prototype.buildTransformConfigDom = function(gristDoc) {
return this.buildConfigDom(gristDoc, true);
};
// clean up old koform styles

View File

@@ -499,7 +499,7 @@ export class FieldBuilder extends Disposable {
// the dom created by the widgetImpl to get out of sync.
return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div', widget.buildConfigDom())
dom('div', widget.buildConfigDom(this.gristDoc))
)
);
}

View File

@@ -1,4 +1,5 @@
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
import { GristDoc } from 'app/client/components/GristDoc';
import { fromKoSave } from 'app/client/lib/fromKoSave';
import { makeT } from 'app/client/lib/localization';
import { DataRowModel } from 'app/client/models/DataRowModel';
@@ -32,7 +33,7 @@ export class NTextBox extends NewAbstractWidget {
}));
}
public buildConfigDom(): DomContents {
public buildConfigDom(_gristDoc: GristDoc): DomContents {
const toggle = () => {
const newValue = !this.field.config.wrap.peek();
this.field.config.wrap.setAndSave(newValue).catch(reportError);

View File

@@ -60,7 +60,7 @@ export abstract class NewAbstractWidget extends Disposable {
/**
* Builds the DOM showing configuration buttons and fields in the sidebar.
*/
public buildConfigDom(): DomContents {
public buildConfigDom(_gristDoc: GristDoc): DomContents {
return null;
}
@@ -68,7 +68,7 @@ export abstract class NewAbstractWidget extends Disposable {
* Builds the transform prompt config DOM in the few cases where it is necessary.
* Child classes need not override this function if they do not require transform config options.
*/
public buildTransformConfigDom(): DomContents {
public buildTransformConfigDom(_gristDoc: GristDoc): DomContents {
return null;
}

View File

@@ -2,6 +2,7 @@
* See app/common/NumberFormat for description of options we support.
*/
import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig';
import {GristDoc} from 'app/client/components/GristDoc';
import {fromKoSave} from 'app/client/lib/fromKoSave';
import {makeT} from 'app/client/lib/localization';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
@@ -39,7 +40,7 @@ export class NumericTextBox extends NTextBox {
super(field);
}
public buildConfigDom(): DomContents {
public buildConfigDom(gristDoc: GristDoc): DomContents {
// Holder for all computeds created here. It gets disposed with the returned DOM element.
const holder = new MultiHolder();
@@ -89,7 +90,7 @@ export class NumericTextBox extends NTextBox {
const disabledStyle = cssButtonSelect.cls('-disabled', disabled);
return [
super.buildConfigDom(),
super.buildConfigDom(gristDoc),
cssLabel(t('Number Format')),
cssRow(
dom.autoDispose(holder),

View File

@@ -4,6 +4,7 @@ import {
FormSelectConfig
} from 'app/client/components/Forms/FormConfig';
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel';
@@ -53,12 +54,12 @@ export class Reference extends NTextBox {
});
}
public buildConfigDom() {
public buildConfigDom(gristDoc: GristDoc) {
return [
this.buildTransformConfigDom(),
dom.create(DropdownConditionConfig, this.field),
dom.create(DropdownConditionConfig, this.field, gristDoc),
cssLabel(t('CELL FORMAT')),
super.buildConfigDom(),
super.buildConfigDom(gristDoc),
];
}

View File

@@ -23,8 +23,8 @@ export class ReferenceEditor extends NTextEditor {
constructor(options: FieldOptions) {
super(options);
const docData = options.gristDoc.docData;
this._utils = new ReferenceUtils(options.field, docData);
const gristDoc = options.gristDoc;
this._utils = new ReferenceUtils(options.field, gristDoc);
const vcol = this._utils.visibleColModel;
this._enableAddNew = (
@@ -47,7 +47,7 @@ export class ReferenceEditor extends NTextEditor {
// The referenced table has probably already been fetched (because there must already be a
// Reference widget instantiated), but it's better to avoid this assumption.
docData.fetchTable(this._utils.refTableId).then(() => {
gristDoc.docData.fetchTable(this._utils.refTableId).then(() => {
if (this.isDisposed()) { return; }
if (needReload && this.textInput.value === '') {
this.textInput.value = undef(options.state, options.editValue, this._idToText());

View File

@@ -55,8 +55,8 @@ export class ReferenceListEditor extends NewBaseEditor {
constructor(protected options: FieldOptions) {
super(options);
const docData = options.gristDoc.docData;
this._utils = new ReferenceUtils(options.field, docData);
const gristDoc = options.gristDoc;
this._utils = new ReferenceUtils(options.field, gristDoc);
const vcol = this._utils.visibleColModel;
this._enableAddNew = (
@@ -130,7 +130,7 @@ export class ReferenceListEditor extends NewBaseEditor {
// The referenced table has probably already been fetched (because there must already be a
// Reference widget instantiated), but it's better to avoid this assumption.
docData.fetchTable(this._utils.refTableId).then(() => {
gristDoc.docData.fetchTable(this._utils.refTableId).then(() => {
if (this.isDisposed()) { return; }
if (needReload) {
this._tokenField.setTokens(