diff --git a/app/client/components/DropdownConditionConfig.ts b/app/client/components/DropdownConditionConfig.ts index a07da785..1094f825 100644 --- a/app/client/components/DropdownConditionConfig.ts +++ b/app/client/components/DropdownConditionConfig.ts @@ -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; `); diff --git a/app/client/components/TypeTransform.ts b/app/client/components/TypeTransform.ts index c47fcd86..0cc4cbd9 100644 --- a/app/client/components/TypeTransform.ts +++ b/app/client/components/TypeTransform.ts @@ -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, () => diff --git a/app/client/lib/ReferenceUtils.ts b/app/client/lib/ReferenceUtils.ts index 04bf6716..a5fa381c 100644 --- a/app/client/lib/ReferenceUtils.ts +++ b/app/client/lib/ReferenceUtils.ts @@ -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>; + private readonly _docData = this._gristDoc.docData; private _dropdownConditionError = Observable.create(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}); }; } } diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 4805218e..f0a2683b 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -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; isFork: Observable; isRecoveryMode: Observable; + user: Observable; userOverride: Observable; isBareFork: Observable; isSnapshot: Observable; @@ -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, diff --git a/app/client/widgets/AbstractWidget.js b/app/client/widgets/AbstractWidget.js index b7a1c23b..bee22436 100644 --- a/app/client/widgets/AbstractWidget.js +++ b/app/client/widgets/AbstractWidget.js @@ -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; }; diff --git a/app/client/widgets/ChoiceEditor.js b/app/client/widgets/ChoiceEditor.js index f01fdbfa..7f32aaf9 100644 --- a/app/client/widgets/ChoiceEditor.js +++ b/app/client/widgets/ChoiceEditor.js @@ -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, }); diff --git a/app/client/widgets/ChoiceListEditor.ts b/app/client/widgets/ChoiceListEditor.ts index 238f47f0..1837ff2c 100644 --- a/app/client/widgets/ChoiceListEditor.ts +++ b/app/client/widgets/ChoiceListEditor.ts @@ -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', ` diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index d2740947..4f7dfc5c 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -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(), ]; } diff --git a/app/client/widgets/DateTimeTextBox.js b/app/client/widgets/DateTimeTextBox.js index 4c6424ce..da8db6fb 100644 --- a/app/client/widgets/DateTimeTextBox.js +++ b/app/client/widgets/DateTimeTextBox.js @@ -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 diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 1e4511fa..35d93221 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -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)) ) ); } diff --git a/app/client/widgets/NTextBox.ts b/app/client/widgets/NTextBox.ts index e939c315..b346c4d6 100644 --- a/app/client/widgets/NTextBox.ts +++ b/app/client/widgets/NTextBox.ts @@ -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); diff --git a/app/client/widgets/NewAbstractWidget.ts b/app/client/widgets/NewAbstractWidget.ts index 86812e20..947d8694 100644 --- a/app/client/widgets/NewAbstractWidget.ts +++ b/app/client/widgets/NewAbstractWidget.ts @@ -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; } diff --git a/app/client/widgets/NumericTextBox.ts b/app/client/widgets/NumericTextBox.ts index 04470433..8e52029a 100644 --- a/app/client/widgets/NumericTextBox.ts +++ b/app/client/widgets/NumericTextBox.ts @@ -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), diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts index 81da964b..76e9d2dd 100644 --- a/app/client/widgets/Reference.ts +++ b/app/client/widgets/Reference.ts @@ -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), ]; } diff --git a/app/client/widgets/ReferenceEditor.ts b/app/client/widgets/ReferenceEditor.ts index 79358917..5cbe3af7 100644 --- a/app/client/widgets/ReferenceEditor.ts +++ b/app/client/widgets/ReferenceEditor.ts @@ -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()); diff --git a/app/client/widgets/ReferenceListEditor.ts b/app/client/widgets/ReferenceListEditor.ts index c2060199..bf2bedda 100644 --- a/app/client/widgets/ReferenceListEditor.ts +++ b/app/client/widgets/ReferenceListEditor.ts @@ -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( diff --git a/app/common/DocListAPI.ts b/app/common/DocListAPI.ts index 06639632..c7803104 100644 --- a/app/common/DocListAPI.ts +++ b/app/common/DocListAPI.ts @@ -3,6 +3,7 @@ import {TableDataAction} from 'app/common/DocActions'; import {FilteredDocUsageSummary} from 'app/common/DocUsage'; import {Role} from 'app/common/roles'; import {StringUnion} from 'app/common/StringUnion'; +import {UserInfo} from 'app/common/User'; import {FullUser} from 'app/common/UserAPI'; // Possible flavors of items in a list of documents. @@ -75,6 +76,7 @@ export interface OpenLocalDocResult { doc: {[tableId: string]: TableDataAction}; log: MinimalActionGroup[]; isTimingOn: boolean; + user: UserInfo; recoveryMode?: boolean; userOverride?: UserOverride; docUsage?: FilteredDocUsageSummary; diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index 3f3b7769..e207bae4 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -1,7 +1,6 @@ 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'; export interface RuleSet { @@ -25,12 +24,6 @@ export interface RulePart { memo?: string; } -// Light wrapper for reading records or user attributes. -export interface InfoView { - get(key: string): CellValue; - toJSON(): {[key: string]: any}; -} - // As InfoView, but also supporting writing. export interface InfoEditor { get(key: string): CellValue; @@ -38,22 +31,6 @@ export interface InfoEditor { toJSON(): {[key: string]: any}; } -// Represents user info, which may include properties which are themselves RowRecords. -export interface UserInfo { - Name: string | null; - Email: string | null; - Access: Role | null; - Origin: string | null; - LinkKey: Record; - UserID: number | null; - UserRef: string | null; - SessionID: string | null; - ShareRef: number | null; // This is a rowId in the _grist_Shares table, if the user - // is accessing a document via a share. Otherwise null. - [attributes: string]: unknown; - toJSON(): {[key: string]: any}; -} - export interface UserAttributeRule { origRecord?: RowRecord; // Original record used to create this UserAttributeRule. name: string; // Should be unique among UserAttributeRules. diff --git a/app/common/PredicateFormula.ts b/app/common/PredicateFormula.ts index 2d9d5f30..6a3b1250 100644 --- a/app/common/PredicateFormula.ts +++ b/app/common/PredicateFormula.ts @@ -10,7 +10,8 @@ */ import {CellValue, RowRecord} from 'app/common/DocActions'; import {ErrorWithCode} from 'app/common/ErrorWithCode'; -import {InfoView, UserInfo} from 'app/common/GranularAccessClause'; +import {InfoView} from 'app/common/RecordView'; +import {UserInfo} from 'app/common/User'; import {decodeObject} from 'app/plugin/objtypes'; import constant = require('lodash/constant'); @@ -31,11 +32,6 @@ export interface PredicateFormulaInput { choice?: string|RowRecord|InfoView; } -export class EmptyRecordView implements InfoView { - public get(_colId: string): CellValue { return null; } - public toJSON() { return {}; } -} - /** * The result of compiling ParsedPredicateFormula. */ @@ -102,7 +98,7 @@ export function compilePredicateFormula( break; } case 'dropdown-condition': { - validNames = ['rec', 'choice']; + validNames = ['rec', 'choice', 'user']; break; } } diff --git a/app/common/RecordView.ts b/app/common/RecordView.ts new file mode 100644 index 00000000..d1359b0c --- /dev/null +++ b/app/common/RecordView.ts @@ -0,0 +1,43 @@ +import {CellValue, TableDataAction} from 'app/common/DocActions'; + +/** Light wrapper for reading records or user attributes. */ +export interface InfoView { + get(key: string): CellValue; + toJSON(): {[key: string]: any}; +} + +/** + * A row-like view of TableDataAction, which is columnar in nature. + * + * If index value is undefined, acts as an EmptyRecordRow. + */ +export class RecordView implements InfoView { + public constructor(public data: TableDataAction, public index: number|undefined) { + } + + public get(colId: string): CellValue { + if (this.index === undefined) { return null; } + if (colId === 'id') { + return this.data[2][this.index]; + } + return this.data[3][colId]?.[this.index]; + } + + public has(colId: string) { + return colId === 'id' || colId in this.data[3]; + } + + public toJSON() { + if (this.index === undefined) { return {}; } + const results: {[key: string]: any} = {id: this.index}; + for (const key of Object.keys(this.data[3])) { + results[key] = this.data[3][key]?.[this.index]; + } + return results; + } +} + +export class EmptyRecordView implements InfoView { + public get(_colId: string): CellValue { return null; } + public toJSON() { return {}; } +} diff --git a/app/common/User.ts b/app/common/User.ts new file mode 100644 index 00000000..4e3419a1 --- /dev/null +++ b/app/common/User.ts @@ -0,0 +1,89 @@ +import {getTableId} from 'app/common/DocActions'; +import {EmptyRecordView, RecordView} from 'app/common/RecordView'; +import {Role} from 'app/common/roles'; + +/** + * Information about a user, including any user attributes. + */ +export interface UserInfo { + Name: string | null; + Email: string | null; + Access: Role | null; + Origin: string | null; + LinkKey: Record; + UserID: number | null; + UserRef: string | null; + SessionID: string | null; + /** + * This is a rowId in the _grist_Shares table, if the user is accessing a document + * via a share. Otherwise null. + */ + ShareRef: number | null; + [attributes: string]: unknown; +} + +/** + * Wrapper class for `UserInfo`. + * + * Contains methods for converting itself to different representations. + */ +export class User implements UserInfo { + public Name: string | null = null; + public UserID: number | null = null; + public Access: Role | null = null; + public Origin: string | null = null; + public LinkKey: Record = {}; + public Email: string | null = null; + public SessionID: string | null = null; + public UserRef: string | null = null; + public ShareRef: number | null = null; + [attribute: string]: any; + + constructor(info: Record = {}) { + Object.assign(this, info); + } + + /** + * Returns a JSON representation of this class that excludes full row data, + * only keeping user info and table/row ids for any user attributes. + * + * Used by the sandbox to support `user` variables in formulas (see `user.py`). + */ + public toJSON() { + return this._toObject((value) => { + if (value instanceof RecordView) { + return [getTableId(value.data), value.get('id')]; + } else if (value instanceof EmptyRecordView) { + return null; + } else { + return value; + } + }); + } + + /** + * Returns a record representation of this class, with all user attributes + * converted from `RecordView` instances to their JSON representations. + * + * Used by the client to support `user` variables in dropdown conditions. + */ + public toUserInfo(): UserInfo { + return this._toObject((value) => { + if (value instanceof RecordView) { + return value.toJSON(); + } else if (value instanceof EmptyRecordView) { + return null; + } else { + return value; + } + }) as UserInfo; + } + + private _toObject(mapValue: (value: unknown) => unknown) { + const results: {[key: string]: any} = {}; + for (const [key, value] of Object.entries(this)) { + results[key] = mapValue(value); + } + return results; + } +} diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index a322077b..addf1278 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -443,6 +443,10 @@ export class ActiveDoc extends EventEmitter { return this._granularAccess.filterDocUsageSummary(docSession, this.getDocUsageSummary()); } + public getUser(docSession: OptDocSession) { + return this._granularAccess.getUser(docSession); + } + public async getUserOverride(docSession: OptDocSession) { return this._granularAccess.getUserOverride(docSession); } diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index 3222ae08..437559f8 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -395,9 +395,10 @@ export class DocManager extends EventEmitter { } } - const [metaTables, recentActions, userOverride] = await Promise.all([ + const [metaTables, recentActions, user, userOverride] = await Promise.all([ activeDoc.fetchMetaTables(docSession), activeDoc.getRecentMinimalActions(docSession), + activeDoc.getUser(docSession), activeDoc.getUserOverride(docSession), ]); @@ -414,6 +415,7 @@ export class DocManager extends EventEmitter { doc: metaTables, log: recentActions, recoveryMode: activeDoc.recoveryMode, + user: user.toUserInfo(), userOverride, docUsage, isTimingOn: activeDoc.isTimingOn, diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index e6116d36..69fcf2ee 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -26,12 +26,14 @@ 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 { InfoEditor, InfoView, UserInfo } from 'app/common/GranularAccessClause'; +import { InfoEditor } 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 { compilePredicateFormula, PredicateFormulaInput } from 'app/common/PredicateFormula'; import { MetaRowRecord, SingleCell } from 'app/common/TableData'; +import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView'; import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; +import { User } from 'app/common/User'; import { FullUser, UserAccessData } from 'app/common/UserAPI'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { GristObjCode } from 'app/plugin/GristData'; @@ -330,11 +332,106 @@ export class GranularAccess implements GranularAccessForBundle { this._userAttributesMap = new WeakMap(); } - public getUser(docSession: OptDocSession): Promise { - return this._getUser(docSession); + /** + * Construct the UserInfo needed for evaluating rules. This also enriches the user with values + * created by user-attribute rules. + */ + public async getUser(docSession: OptDocSession): Promise { + const linkParameters = docSession.authorizer?.getLinkParameters() || {}; + let access: Role | null; + let fullUser: FullUser | null; + const attrs = this._getUserAttributes(docSession); + access = getDocSessionAccess(docSession); + + const linkId = getDocSessionShare(docSession); + let shareRef: number = 0; + if (linkId) { + const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({ + linkId, + }); + if (rowIds.length > 1) { + throw new Error('Share identifier is not unique'); + } + if (rowIds.length === 1) { + shareRef = rowIds[0]; + } + } + + if (docSession.forkingAsOwner) { + // For granular access purposes, we become an owner. + // It is a bit of a bluff, done on the understanding that this session will + // never be used to edit the document, and that any edits will be done on a + // fork. + access = 'owners'; + } + + // If aclAsUserId/aclAsUser is set, then override user for acl purposes. + if (linkParameters.aclAsUserId || linkParameters.aclAsUser) { + if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); } + if (attrs.override) { + // Used cached properties. + access = attrs.override.access; + fullUser = attrs.override.user; + } else { + attrs.override = await this._getViewAsUser(linkParameters); + fullUser = attrs.override.user; + } + } else { + fullUser = getDocSessionUser(docSession); + } + const user = new User(); + user.Access = access; + user.ShareRef = shareRef || null; + const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() || + fullUser?.id === null; + user.UserID = (!isAnonymous && fullUser?.id) || null; + user.Email = fullUser?.email || null; + user.Name = fullUser?.name || null; + // If viewed from a websocket, collect any link parameters included. + // TODO: could also get this from rest api access, just via a different route. + user.LinkKey = linkParameters; + // Include origin info if accessed via the rest api. + // TODO: could also get this for websocket access, just via a different route. + user.Origin = docSession.req?.get('origin') || null; + user.SessionID = isAnonymous ? `a${getDocSessionAltSessionId(docSession)}` : `u${user.UserID}`; + user.IsLoggedIn = !isAnonymous; + user.UserRef = fullUser?.ref || null; // Empty string should be treated as null. + + if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) { + // It is important to signal that the doc is in an unexpected state, + // and prevent it opening. + throw this._ruler.ruleCollection.ruleError; + } + + for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) { + if (clause.name in user) { + log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`); + continue; + } + if (attrs.rows[clause.name]) { + user[clause.name] = attrs.rows[clause.name]; + continue; + } + let rec = new EmptyRecordView(); + let rows: TableDataAction|undefined; + try { + // Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`. + // TODO: add indexes to db. + rows = await this._fetchQueryFromDB({ + tableId: clause.tableId, + filters: { [clause.lookupColId]: [get(user, clause.charId)] } + }); + } catch (e) { + log.warn(`User attribute ${clause.name} failed`, e); + } + if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); } + user[clause.name] = rec; + attrs.rows[clause.name] = rec; + } + return user; } - public async getCachedUser(docSession: OptDocSession): Promise { + public async getCachedUser(docSession: OptDocSession): Promise { const access = await this._getAccess(docSession); return access.getUser(); } @@ -345,7 +442,7 @@ export class GranularAccess implements GranularAccessForBundle { */ public async inputs(docSession: OptDocSession): Promise { return { - user: await this._getUser(docSession), + user: await this.getUser(docSession), docId: this._docId }; } @@ -479,7 +576,7 @@ export class GranularAccess implements GranularAccessForBundle { public async canApplyBundle() { if (!this._activeBundle) { throw new Error('no active bundle'); } const {docActions, docSession, isDirect} = this._activeBundle; - const currentUser = await this._getUser(docSession); + const currentUser = await this.getUser(docSession); const userIsOwner = await this.isOwner(docSession); if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) { throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules'); @@ -1004,7 +1101,7 @@ export class GranularAccess implements GranularAccessForBundle { } public async getUserOverride(docSession: OptDocSession): Promise { - await this._getUser(docSession); + await this.getUser(docSession); return this._getUserAttributes(docSession).override; } @@ -1120,7 +1217,7 @@ export class GranularAccess implements GranularAccessForBundle { const linkParameters = docSession.authorizer?.getLinkParameters() || {}; const baseAccess = getDocSessionAccess(docSession); if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') { - const info = await this._getUser(docSession); + const info = await this.getUser(docSession); return info.Access; } return baseAccess; @@ -1838,105 +1935,6 @@ export class GranularAccess implements GranularAccessForBundle { } } - /** - * Construct the UserInfo needed for evaluating rules. This also enriches the user with values - * created by user-attribute rules. - */ - private async _getUser(docSession: OptDocSession): Promise { - const linkParameters = docSession.authorizer?.getLinkParameters() || {}; - let access: Role | null; - let fullUser: FullUser | null; - const attrs = this._getUserAttributes(docSession); - access = getDocSessionAccess(docSession); - - const linkId = getDocSessionShare(docSession); - let shareRef: number = 0; - if (linkId) { - const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({ - linkId, - }); - if (rowIds.length > 1) { - throw new Error('Share identifier is not unique'); - } - if (rowIds.length === 1) { - shareRef = rowIds[0]; - } - } - - if (docSession.forkingAsOwner) { - // For granular access purposes, we become an owner. - // It is a bit of a bluff, done on the understanding that this session will - // never be used to edit the document, and that any edits will be done on a - // fork. - access = 'owners'; - } - - // If aclAsUserId/aclAsUser is set, then override user for acl purposes. - if (linkParameters.aclAsUserId || linkParameters.aclAsUser) { - if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); } - if (attrs.override) { - // Used cached properties. - access = attrs.override.access; - fullUser = attrs.override.user; - } else { - attrs.override = await this._getViewAsUser(linkParameters); - fullUser = attrs.override.user; - } - } else { - fullUser = getDocSessionUser(docSession); - } - const user = new User(); - user.Access = access; - user.ShareRef = shareRef || null; - const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() || - fullUser?.id === null; - user.UserID = (!isAnonymous && fullUser?.id) || null; - user.Email = fullUser?.email || null; - user.Name = fullUser?.name || null; - // If viewed from a websocket, collect any link parameters included. - // TODO: could also get this from rest api access, just via a different route. - user.LinkKey = linkParameters; - // Include origin info if accessed via the rest api. - // TODO: could also get this for websocket access, just via a different route. - user.Origin = docSession.req?.get('origin') || null; - user.SessionID = isAnonymous ? `a${getDocSessionAltSessionId(docSession)}` : `u${user.UserID}`; - user.IsLoggedIn = !isAnonymous; - user.UserRef = fullUser?.ref || null; // Empty string should be treated as null. - - if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) { - // It is important to signal that the doc is in an unexpected state, - // and prevent it opening. - throw this._ruler.ruleCollection.ruleError; - } - - for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) { - if (clause.name in user) { - log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`); - continue; - } - if (attrs.rows[clause.name]) { - user[clause.name] = attrs.rows[clause.name]; - continue; - } - let rec = new EmptyRecordView(); - let rows: TableDataAction|undefined; - try { - // Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`. - // TODO: add indexes to db. - rows = await this._fetchQueryFromDB({ - tableId: clause.tableId, - filters: { [clause.lookupColId]: [get(user, clause.charId)] } - }); - } catch (e) { - log.warn(`User attribute ${clause.name} failed`, e); - } - if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); } - user[clause.name] = rec; - attrs.rows[clause.name] = rec; - } - return user; - } - /** * Get the "View As" user specified in link parameters. * If aclAsUserId is set, we get the user with the specified id. @@ -2583,7 +2581,7 @@ export class GranularAccess implements GranularAccessForBundle { /** * Tests if the user can modify cell's data. */ - private async _canApplyCellActions(currentUser: UserInfo, userIsOwner: boolean) { + private async _canApplyCellActions(currentUser: User, userIsOwner: boolean) { // Owner can modify all comments, without exceptions. if (userIsOwner) { return; @@ -2654,7 +2652,7 @@ export class Ruler { } export interface RulerOwner { - getUser(docSession: OptDocSession): Promise; + getUser(docSession: OptDocSession): Promise; inputs(docSession: OptDocSession): Promise; } @@ -2688,36 +2686,6 @@ interface ActionCursor { // access control state. } -/** - * A row-like view of TableDataAction, which is columnar in nature. If index value - * is undefined, acts as an EmptyRecordRow. - */ -export class RecordView implements InfoView { - public constructor(public data: TableDataAction, public index: number|undefined) { - } - - public get(colId: string): CellValue { - if (this.index === undefined) { return null; } - if (colId === 'id') { - return this.data[2][this.index]; - } - return this.data[3][colId]?.[this.index]; - } - - public has(colId: string) { - return colId === 'id' || colId in this.data[3]; - } - - public toJSON() { - if (this.index === undefined) { return {}; } - const results: {[key: string]: any} = {}; - for (const key of Object.keys(this.data[3])) { - results[key] = this.data[3][key]?.[this.index]; - } - return results; - } -} - /** * A read-write view of a DataAction, for use in censorship. */ @@ -3222,47 +3190,6 @@ export function filterColValues(action: DataAction, return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)]; } -/** - * Information about a user, including any user attributes. - * - * Serializes into a more compact JSON form that excludes full - * row data, only keeping user info and table/row ids for any - * user attributes. - * - * See `user.py` for the sandbox equivalent that deserializes objects of this class. - */ -export class User implements UserInfo { - public Name: string | null = null; - public UserID: number | null = null; - public Access: Role | null = null; - public Origin: string | null = null; - public LinkKey: Record = {}; - public Email: string | null = null; - public SessionID: string | null = null; - public UserRef: string | null = null; - public ShareRef: number | null = null; - [attribute: string]: any; - - constructor(_info: Record = {}) { - Object.assign(this, _info); - } - - public toJSON() { - const results: {[key: string]: any} = {}; - for (const [key, value] of Object.entries(this)) { - if (value instanceof RecordView) { - // Only include the table id and first matching row id. - results[key] = [getTableId(value.data), value.get('id')]; - } else if (value instanceof EmptyRecordView) { - results[key] = null; - } else { - results[key] = value; - } - } - return results; - } -} - export function validTableIdString(tableId: any): string { if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); } return tableId; diff --git a/app/server/lib/PermissionInfo.ts b/app/server/lib/PermissionInfo.ts index 7ef7860f..d779bf96 100644 --- a/app/server/lib/PermissionInfo.ts +++ b/app/server/lib/PermissionInfo.ts @@ -3,8 +3,9 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet, MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet, toMixed } from 'app/common/ACLPermissions'; import { ACLRuleCollection } from 'app/common/ACLRuleCollection'; -import { RuleSet, UserInfo } from 'app/common/GranularAccessClause'; +import { RuleSet } from 'app/common/GranularAccessClause'; import { PredicateFormulaInput } from 'app/common/PredicateFormula'; +import { User } from 'app/common/User'; import { getSetMapValue } from 'app/common/gutil'; import log from 'app/server/lib/log'; import { mapValues } from 'lodash'; @@ -80,8 +81,8 @@ abstract class RuleInfo { return this._mergeFullAccess(tableAccess); } - public getUser(): UserInfo { - return this._input.user!; + public getUser(): User { + return this._input.user! as User; } protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT; diff --git a/test/nbrowser/DropdownConditionEditor.ts b/test/nbrowser/DropdownConditionEditor.ts index cdab44ab..9dff24b8 100644 --- a/test/nbrowser/DropdownConditionEditor.ts +++ b/test/nbrowser/DropdownConditionEditor.ts @@ -1,3 +1,4 @@ +import {UserAPI} from 'app/common/UserAPI'; import {assert, driver, Key} from 'mocha-webdriver'; import * as gu from 'test/nbrowser/gristUtils'; import {setupTestSuite} from 'test/nbrowser/testUtils'; @@ -5,22 +6,56 @@ import {setupTestSuite} from 'test/nbrowser/testUtils'; describe('DropdownConditionEditor', function () { this.timeout(20000); const cleanup = setupTestSuite(); + let api: UserAPI; + let docId: string; before(async () => { - const session = await gu.session().login(); - await session.tempDoc(cleanup, 'DropdownCondition.grist'); + const session = await gu.session().user('user1').login(); + api = session.createHomeApi(); + docId = (await session.tempDoc(cleanup, 'DropdownCondition.grist')).id; + await api.updateDocPermissions(docId, {users: { + [gu.translateUser('user2').email]: 'editors', + }}); + await addUserAttributes(); + await gu.openPage('Employees'); await gu.openColumnPanel(); }); afterEach(() => gu.checkForErrors()); + async function addUserAttributes() { + await api.applyUserActions(docId, [ + ['AddTable', 'Roles', [{id: 'Email'}, {id: 'Admin', type: 'Bool'}]], + ['AddRecord', 'Roles', null, {Email: gu.translateUser('user1').email, Admin: true}], + ['AddRecord', 'Roles', null, {Email: gu.translateUser('user2').email, Admin: false}], + ]); + await driver.find('.test-tools-access-rules').click(); + await gu.waitForServer(); + await driver.findContentWait('button', /Add User Attributes/, 2000).click(); + const userAttrRule = await driver.find('.test-rule-userattr'); + await userAttrRule.find('.test-rule-userattr-name').click(); + await driver.sendKeys('Roles', Key.ENTER); + await userAttrRule.find('.test-rule-userattr-attr').click(); + await driver.sendKeys('Email', Key.ENTER); + await userAttrRule.find('.test-rule-userattr-table').click(); + await driver.findContent('.test-select-menu li', 'Roles').click(); + await userAttrRule.find('.test-rule-userattr-col').click(); + await driver.sendKeys('Email', Key.ENTER); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + } + describe(`in choice columns`, function() { + before(async () => { + const session = await gu.session().user('user1').login(); + await session.loadDoc(`/doc/${docId}`); + }); + it('creates dropdown conditions', async function() { await gu.getCell(1, 1).click(); - assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent()); - await driver.find('.test-field-set-dropdown-condition').click(); + await driver.find('.test-field-dropdown-condition').click(); await gu.waitAppFocus(false); - await gu.sendKeys('c'); + await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'c'); await gu.waitToPass(async () => { const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); assert.deepEqual(completions, [ @@ -28,6 +63,7 @@ describe('DropdownConditionEditor', function () { 're\nc\n.Name\n ', 're\nc\n.Role\n ', 're\nc\n.Supervisor\n ', + 'user.A\nc\ncess\n ', ]); }); await gu.sendKeysSlowly(['hoice not in $']); @@ -141,6 +177,11 @@ describe('DropdownConditionEditor', function () { }); describe(`in reference columns`, function() { + before(async () => { + const session = await gu.session().user('user1').login(); + await session.loadDoc(`/doc/${docId}`); + }); + it('creates dropdown conditions', async function() { await gu.getCell(2, 1).click(); assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent()); @@ -288,4 +329,44 @@ describe('DropdownConditionEditor', function () { await gu.sendKeys(Key.ESCAPE); }); }); + + it('supports user variable', async function() { + // Filter dropdown values based on a user attribute. + await gu.getCell(1, 1).click(); + await driver.find('.test-field-set-dropdown-condition').click(); + await gu.waitAppFocus(false); + await gu.sendKeysSlowly(['user.']); + await gu.waitToPass(async () => { + const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); + assert.deepEqual(completions, [ + 'user.\nAccess\n ', + 'user.\nEmail\n ', + 'user.\nIsLoggedIn\n ', + 'user.\nLinkKey.\n ', + 'user.\nName\n ', + 'user.\nOrigin\n ', + 'user.\nRoles.Admin\n ', + 'user.\nRoles.Email\n ', + '' + ]); + }); + await gu.sendKeys('Roles.Admin == True', Key.ENTER); + await gu.waitForServer(); + + // Check that user1 (who is an admin) can see dropdown values. + await gu.sendKeys(Key.ENTER); + assert.deepEqual(await driver.findAll('.test-autocomplete li', (el) => el.getText()), [ + 'Trainee', + 'Supervisor', + ]); + await gu.sendKeys(Key.ESCAPE); + + // Switch to user2 (who is not an admin), and check that they can't see any dropdown values. + const session = await gu.session().user('user2').login(); + await session.loadDoc(`/doc/${docId}`); + await gu.getCell(1, 1).click(); + await gu.sendKeys(Key.ENTER); + assert.deepEqual(await driver.findAll('.test-autocomplete li', (el) => el.getText()), []); + await gu.sendKeys(Key.ESCAPE); + }); }); diff --git a/test/server/lib/ACLFormula.ts b/test/server/lib/ACLFormula.ts index 8951c0f9..1fcd89dc 100644 --- a/test/server/lib/ACLFormula.ts +++ b/test/server/lib/ACLFormula.ts @@ -1,9 +1,9 @@ import {CellValue} from 'app/common/DocActions'; -import {InfoView} from 'app/common/GranularAccessClause'; import {GristObjCode} from 'app/plugin/GristData'; import {CompiledPredicateFormula, compilePredicateFormula} from 'app/common/PredicateFormula'; +import {InfoView} from 'app/common/RecordView'; +import {User} from 'app/common/User'; import {makeExceptionalDocSession} from 'app/server/lib/DocSession'; -import {User} from 'app/server/lib/GranularAccess'; import {assert} from 'chai'; import {createDocTools} from 'test/server/docTools'; import * as testUtils from 'test/server/testUtils';