mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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:
		
							parent
							
								
									50077540e2
								
							
						
					
					
						commit
						72066bf0e4
					
				@ -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;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
@ -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, () =>
 | 
			
		||||
 | 
			
		||||
@ -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});
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -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', `
 | 
			
		||||
 | 
			
		||||
@ -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(),
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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))
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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),
 | 
			
		||||
 | 
			
		||||
@ -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),
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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());
 | 
			
		||||
 | 
			
		||||
@ -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(
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
@ -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<string, string | undefined>;
 | 
			
		||||
  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.
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										43
									
								
								app/common/RecordView.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								app/common/RecordView.ts
									
									
									
									
									
										Normal file
									
								
							@ -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 {}; }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										89
									
								
								app/common/User.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								app/common/User.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, string | undefined>;
 | 
			
		||||
  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<string, string | undefined> = {};
 | 
			
		||||
  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<string, unknown> = {}) {
 | 
			
		||||
    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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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<UserInfo> {
 | 
			
		||||
    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<User> {
 | 
			
		||||
    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];
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  public async getCachedUser(docSession: OptDocSession): Promise<UserInfo> {
 | 
			
		||||
    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<User> {
 | 
			
		||||
    const access = await this._getAccess(docSession);
 | 
			
		||||
    return access.getUser();
 | 
			
		||||
  }
 | 
			
		||||
@ -345,7 +442,7 @@ export class GranularAccess implements GranularAccessForBundle {
 | 
			
		||||
   */
 | 
			
		||||
  public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
 | 
			
		||||
    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<UserOverride|undefined> {
 | 
			
		||||
    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<UserInfo> {
 | 
			
		||||
    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<UserInfo>;
 | 
			
		||||
  getUser(docSession: OptDocSession): Promise<User>;
 | 
			
		||||
  inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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<string, string | undefined> = {};
 | 
			
		||||
  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<string, unknown> = {}) {
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
@ -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<MixedT extends TableT, TableT> {
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -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';
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user