mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +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 {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {ViewFieldRec} from 'app/client/models/DocModel';
|
import {ViewFieldRec} from 'app/client/models/DocModel';
|
||||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
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 {testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
||||||
import {getPredicateFormulaProperties} from 'app/common/PredicateFormula';
|
import {getPredicateFormulaProperties} from 'app/common/PredicateFormula';
|
||||||
|
import {UserInfo} from 'app/common/User';
|
||||||
import {Computed, Disposable, dom, Observable, styled} from 'grainjs';
|
import {Computed, Disposable, dom, Observable, styled} from 'grainjs';
|
||||||
|
import isPlainObject from 'lodash/isPlainObject';
|
||||||
|
|
||||||
const t = makeT('DropdownConditionConfig');
|
const t = makeT('DropdownConditionConfig');
|
||||||
|
|
||||||
@ -99,7 +102,7 @@ export class DropdownConditionConfig extends Disposable {
|
|||||||
|
|
||||||
private _editorElement: HTMLElement;
|
private _editorElement: HTMLElement;
|
||||||
|
|
||||||
constructor(private _field: ViewFieldRec) {
|
constructor(private _field: ViewFieldRec, private _gristDoc: GristDoc) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.autoDispose(this._text.addListener(() => {
|
this.autoDispose(this._text.addListener(() => {
|
||||||
@ -167,6 +170,10 @@ export class DropdownConditionConfig extends Disposable {
|
|||||||
|
|
||||||
private _getAutocompleteSuggestions(): ISuggestionWithValue[] {
|
private _getAutocompleteSuggestions(): ISuggestionWithValue[] {
|
||||||
const variables = ['choice'];
|
const variables = ['choice'];
|
||||||
|
const user = this._gristDoc.docPageModel.user.get();
|
||||||
|
if (user) {
|
||||||
|
variables.push(...getUserCompletions(user));
|
||||||
|
}
|
||||||
const refColumns = this._refColumns.get();
|
const refColumns = this._refColumns.get();
|
||||||
if (refColumns) {
|
if (refColumns) {
|
||||||
variables.push('choice.id', ...refColumns.map(({colId}) => `choice.${colId.peek()}`));
|
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}) => `$${colId.peek()}`),
|
||||||
...columns.map(({colId}) => `rec.${colId.peek()}`),
|
...columns.map(({colId}) => `rec.${colId.peek()}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
const suggestions = [
|
const suggestions = [
|
||||||
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
|
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
|
||||||
'OWNER', 'EDITOR', 'VIEWER',
|
'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, `
|
const cssSetDropdownConditionRow = styled(cssRow, `
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
`);
|
`);
|
||||||
|
@ -63,7 +63,7 @@ export class TypeTransform extends ColumnTransform {
|
|||||||
if (use(this._isFormWidget)) {
|
if (use(this._isFormWidget)) {
|
||||||
return transformWidget.buildFormTransformConfigDom();
|
return transformWidget.buildFormTransformConfigDom();
|
||||||
} else {
|
} else {
|
||||||
return transformWidget.buildTransformConfigDom();
|
return transformWidget.buildTransformConfigDom(this.gristDoc);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
dom.maybe(this._reviseTypeChange, () =>
|
dom.maybe(this._reviseTypeChange, () =>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {ACIndex, ACResults} from 'app/client/lib/ACIndex';
|
import {ACIndex, ACResults} from 'app/client/lib/ACIndex';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {ICellItem} from 'app/client/models/ColumnACIndexes';
|
import {ICellItem} from 'app/client/models/ColumnACIndexes';
|
||||||
import {ColumnCache} from 'app/client/models/ColumnCache';
|
import {ColumnCache} from 'app/client/models/ColumnCache';
|
||||||
import {DocData} from 'app/client/models/DocData';
|
|
||||||
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
import {TableData} from 'app/client/models/TableData';
|
import {TableData} from 'app/client/models/TableData';
|
||||||
import {getReferencedTableId, isRefListType} from 'app/common/gristTypes';
|
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 {BaseFormatter} from 'app/common/ValueFormatter';
|
||||||
import {Disposable, dom, Observable} from 'grainjs';
|
import {Disposable, dom, Observable} from 'grainjs';
|
||||||
|
|
||||||
@ -26,9 +26,10 @@ export class ReferenceUtils extends Disposable {
|
|||||||
public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text);
|
public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text);
|
||||||
|
|
||||||
private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>;
|
private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>;
|
||||||
|
private readonly _docData = this._gristDoc.docData;
|
||||||
private _dropdownConditionError = Observable.create<string | null>(this, null);
|
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();
|
super();
|
||||||
|
|
||||||
const colType = field.column().type();
|
const colType = field.column().type();
|
||||||
@ -38,7 +39,7 @@ export class ReferenceUtils extends Disposable {
|
|||||||
}
|
}
|
||||||
this.refTableId = refTableId;
|
this.refTableId = refTableId;
|
||||||
|
|
||||||
const tableData = _docData.getTable(refTableId);
|
const tableData = this._docData.getTable(refTableId);
|
||||||
if (!tableData) {
|
if (!tableData) {
|
||||||
throw new Error("Invalid referenced table " + refTableId);
|
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`); }
|
if (!table) { throw new Error(`Table ${tableId} not found`); }
|
||||||
|
|
||||||
const {result: predicate} = dropdownConditionCompiled;
|
const {result: predicate} = dropdownConditionCompiled;
|
||||||
|
const user = this._gristDoc.docPageModel.user.get() ?? undefined;
|
||||||
const rec = table.getRecord(rowId) || new EmptyRecordView();
|
const rec = table.getRecord(rowId) || new EmptyRecordView();
|
||||||
return (item: ICellItem) => {
|
return (item: ICellItem) => {
|
||||||
const choice = item.rowId === 'new' ? new EmptyRecordView() : this.tableData.getRecord(item.rowId);
|
const choice = item.rowId === 'new' ? new EmptyRecordView() : this.tableData.getRecord(item.rowId);
|
||||||
if (!choice) { throw new Error(`Reference ${item.rowId} not found`); }
|
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 {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||||
import {getReconnectTimeout} from 'app/common/gutil';
|
import {getReconnectTimeout} from 'app/common/gutil';
|
||||||
import {canEdit, isOwner} from 'app/common/roles';
|
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 {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
||||||
import {Holder, Observable, subscribe} from 'grainjs';
|
import {Holder, Observable, subscribe} from 'grainjs';
|
||||||
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
||||||
@ -38,6 +39,7 @@ export interface DocInfo extends Document {
|
|||||||
isPreFork: boolean;
|
isPreFork: boolean;
|
||||||
isFork: boolean;
|
isFork: boolean;
|
||||||
isRecoveryMode: boolean;
|
isRecoveryMode: boolean;
|
||||||
|
user: UserInfo|null;
|
||||||
userOverride: UserOverride|null;
|
userOverride: UserOverride|null;
|
||||||
isBareFork: boolean; // a document created without logging in, which is treated as a
|
isBareFork: boolean; // a document created without logging in, which is treated as a
|
||||||
// fork without an original.
|
// fork without an original.
|
||||||
@ -78,6 +80,7 @@ export interface DocPageModel {
|
|||||||
isPrefork: Observable<boolean>;
|
isPrefork: Observable<boolean>;
|
||||||
isFork: Observable<boolean>;
|
isFork: Observable<boolean>;
|
||||||
isRecoveryMode: Observable<boolean>;
|
isRecoveryMode: Observable<boolean>;
|
||||||
|
user: Observable<UserInfo|null>;
|
||||||
userOverride: Observable<UserOverride|null>;
|
userOverride: Observable<UserOverride|null>;
|
||||||
isBareFork: Observable<boolean>;
|
isBareFork: Observable<boolean>;
|
||||||
isSnapshot: 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 isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
|
||||||
public readonly isRecoveryMode = Computed.create(this, this.currentDoc,
|
public readonly isRecoveryMode = Computed.create(this, this.currentDoc,
|
||||||
(use, doc) => doc ? doc.isRecoveryMode : false);
|
(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 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 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);
|
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...
|
// TODO It would be bad if a new doc gets opened while this getDoc() is pending...
|
||||||
const newDoc = await getDoc(this._api, urlId);
|
const newDoc = await getDoc(this._api, urlId);
|
||||||
const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode);
|
const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode);
|
||||||
|
const user = this.currentDoc.get()?.user || null;
|
||||||
const userOverride = this.currentDoc.get()?.userOverride || 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;
|
return newDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,11 +412,13 @@ It also disables formulas. [{{error}}]", {error: err.message})
|
|||||||
linkParameters,
|
linkParameters,
|
||||||
originalUrlId: options.originalUrlId,
|
originalUrlId: options.originalUrlId,
|
||||||
});
|
});
|
||||||
if (openDocResponse.recoveryMode || openDocResponse.userOverride) {
|
const {user, recoveryMode, userOverride} = openDocResponse;
|
||||||
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode);
|
doc.user = user;
|
||||||
doc.userOverride = openDocResponse.userOverride || null;
|
if (recoveryMode || userOverride) {
|
||||||
this.currentDoc.set({...doc});
|
doc.isRecoveryMode = Boolean(recoveryMode);
|
||||||
|
doc.userOverride = userOverride || null;
|
||||||
}
|
}
|
||||||
|
this.currentDoc.set({...doc});
|
||||||
if (openDocResponse.docUsage) {
|
if (openDocResponse.docUsage) {
|
||||||
this.updateCurrentDocUsage(openDocResponse.docUsage);
|
this.updateCurrentDocUsage(openDocResponse.docUsage);
|
||||||
}
|
}
|
||||||
@ -520,6 +527,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
|
|||||||
...doc,
|
...doc,
|
||||||
isFork,
|
isFork,
|
||||||
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
|
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
|
||||||
|
user: null, // ditto.
|
||||||
userOverride: null, // ditto.
|
userOverride: null, // ditto.
|
||||||
isPreFork,
|
isPreFork,
|
||||||
isBareFork,
|
isBareFork,
|
||||||
|
@ -21,7 +21,7 @@ dispose.makeDisposable(AbstractWidget);
|
|||||||
/**
|
/**
|
||||||
* Builds the DOM showing configuration buttons and fields in the sidebar.
|
* 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");
|
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.
|
* 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.
|
* 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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -129,7 +129,7 @@ ChoiceEditor.prototype.buildDropdownConditionFilter = function() {
|
|||||||
|
|
||||||
return buildDropdownConditionFilter({
|
return buildDropdownConditionFilter({
|
||||||
dropdownConditionCompiled: dropdownConditionCompiled.result,
|
dropdownConditionCompiled: dropdownConditionCompiled.result,
|
||||||
docData: this.options.gristDoc.docData,
|
gristDoc: this.options.gristDoc,
|
||||||
tableId: this.options.field.tableId(),
|
tableId: this.options.field.tableId(),
|
||||||
rowId: this.options.rowId,
|
rowId: this.options.rowId,
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import {createGroup} from 'app/client/components/commands';
|
import {createGroup} from 'app/client/components/commands';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {ACIndexImpl, ACItem, ACResults,
|
import {ACIndexImpl, ACItem, ACResults,
|
||||||
buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex';
|
buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex';
|
||||||
import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
|
import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
|
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 {colors, testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {menuCssClass} from 'app/client/ui2018/menus';
|
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||||
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
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 {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||||
import {csvEncodeRow} from 'app/common/csvFormat';
|
import {csvEncodeRow} from 'app/common/csvFormat';
|
||||||
import {CellValue} from "app/common/DocActions";
|
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 {decodeObject, encodeObject} from 'app/plugin/objtypes';
|
||||||
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
|
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
|
||||||
import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken';
|
import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken';
|
||||||
@ -246,7 +247,7 @@ export class ChoiceListEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
return buildDropdownConditionFilter({
|
return buildDropdownConditionFilter({
|
||||||
dropdownConditionCompiled: dropdownConditionCompiled.result,
|
dropdownConditionCompiled: dropdownConditionCompiled.result,
|
||||||
docData: this.options.gristDoc.docData,
|
gristDoc: this.options.gristDoc,
|
||||||
tableId: this.options.field.tableId(),
|
tableId: this.options.field.tableId(),
|
||||||
rowId: this.options.rowId,
|
rowId: this.options.rowId,
|
||||||
});
|
});
|
||||||
@ -311,7 +312,7 @@ export class ChoiceListEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
export interface GetACFilterFuncParams {
|
export interface GetACFilterFuncParams {
|
||||||
dropdownConditionCompiled: CompiledPredicateFormula;
|
dropdownConditionCompiled: CompiledPredicateFormula;
|
||||||
docData: DocData;
|
gristDoc: GristDoc;
|
||||||
tableId: string;
|
tableId: string;
|
||||||
rowId: number;
|
rowId: number;
|
||||||
}
|
}
|
||||||
@ -319,12 +320,13 @@ export interface GetACFilterFuncParams {
|
|||||||
export function buildDropdownConditionFilter(
|
export function buildDropdownConditionFilter(
|
||||||
params: GetACFilterFuncParams
|
params: GetACFilterFuncParams
|
||||||
): (item: ChoiceItem) => boolean {
|
): (item: ChoiceItem) => boolean {
|
||||||
const {dropdownConditionCompiled, docData, tableId, rowId} = params;
|
const {dropdownConditionCompiled, gristDoc, tableId, rowId} = params;
|
||||||
const table = docData.getTable(tableId);
|
const table = gristDoc.docData.getTable(tableId);
|
||||||
if (!table) { throw new Error(`Table ${tableId} not found`); }
|
if (!table) { throw new Error(`Table ${tableId} not found`); }
|
||||||
|
|
||||||
|
const user = gristDoc.docPageModel.user.get() ?? undefined;
|
||||||
const rec = table.getRecord(rowId) || new EmptyRecordView();
|
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', `
|
const cssCellEditor = styled('div', `
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
FormSelectConfig,
|
FormSelectConfig,
|
||||||
} from 'app/client/components/Forms/FormConfig';
|
} from 'app/client/components/Forms/FormConfig';
|
||||||
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
|
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
@ -79,17 +80,17 @@ export class ChoiceTextBox extends NTextBox {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildConfigDom() {
|
public buildConfigDom(gristDoc: GristDoc) {
|
||||||
return [
|
return [
|
||||||
super.buildConfigDom(),
|
super.buildConfigDom(gristDoc),
|
||||||
this.buildChoicesConfigDom(),
|
this.buildChoicesConfigDom(),
|
||||||
dom.create(DropdownConditionConfig, this.field),
|
dom.create(DropdownConditionConfig, this.field, gristDoc),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildTransformConfigDom() {
|
public buildTransformConfigDom(gristDoc: GristDoc) {
|
||||||
return [
|
return [
|
||||||
super.buildConfigDom(),
|
super.buildConfigDom(gristDoc),
|
||||||
this.buildChoicesConfigDom(),
|
this.buildChoicesConfigDom(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ _.extend(DateTimeTextBox.prototype, DateTextBox.prototype);
|
|||||||
* Builds the config dom for the DateTime TextBox. If isTransformConfig is true,
|
* Builds the config dom for the DateTime TextBox. If isTransformConfig is true,
|
||||||
* builds only the necessary dom for the transform config menu.
|
* 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(() => {
|
const disabled = ko.pureComputed(() => {
|
||||||
return this.field.config.options.disabled('timeFormat')() || this.field.column().disableEditData();
|
return this.field.config.options.disabled('timeFormat')() || this.field.column().disableEditData();
|
||||||
});
|
});
|
||||||
@ -92,8 +92,8 @@ DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
DateTimeTextBox.prototype.buildTransformConfigDom = function() {
|
DateTimeTextBox.prototype.buildTransformConfigDom = function(gristDoc) {
|
||||||
return this.buildConfigDom(true);
|
return this.buildConfigDom(gristDoc, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// clean up old koform styles
|
// 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.
|
// the dom created by the widgetImpl to get out of sync.
|
||||||
return dom('div',
|
return dom('div',
|
||||||
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
|
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 { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
|
||||||
|
import { GristDoc } from 'app/client/components/GristDoc';
|
||||||
import { fromKoSave } from 'app/client/lib/fromKoSave';
|
import { fromKoSave } from 'app/client/lib/fromKoSave';
|
||||||
import { makeT } from 'app/client/lib/localization';
|
import { makeT } from 'app/client/lib/localization';
|
||||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
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 toggle = () => {
|
||||||
const newValue = !this.field.config.wrap.peek();
|
const newValue = !this.field.config.wrap.peek();
|
||||||
this.field.config.wrap.setAndSave(newValue).catch(reportError);
|
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.
|
* Builds the DOM showing configuration buttons and fields in the sidebar.
|
||||||
*/
|
*/
|
||||||
public buildConfigDom(): DomContents {
|
public buildConfigDom(_gristDoc: GristDoc): DomContents {
|
||||||
return null;
|
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.
|
* 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.
|
* 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
* See app/common/NumberFormat for description of options we support.
|
* See app/common/NumberFormat for description of options we support.
|
||||||
*/
|
*/
|
||||||
import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig';
|
import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {fromKoSave} from 'app/client/lib/fromKoSave';
|
import {fromKoSave} from 'app/client/lib/fromKoSave';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
@ -39,7 +40,7 @@ export class NumericTextBox extends NTextBox {
|
|||||||
super(field);
|
super(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildConfigDom(): DomContents {
|
public buildConfigDom(gristDoc: GristDoc): DomContents {
|
||||||
// Holder for all computeds created here. It gets disposed with the returned DOM element.
|
// Holder for all computeds created here. It gets disposed with the returned DOM element.
|
||||||
const holder = new MultiHolder();
|
const holder = new MultiHolder();
|
||||||
|
|
||||||
@ -89,7 +90,7 @@ export class NumericTextBox extends NTextBox {
|
|||||||
const disabledStyle = cssButtonSelect.cls('-disabled', disabled);
|
const disabledStyle = cssButtonSelect.cls('-disabled', disabled);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
super.buildConfigDom(),
|
super.buildConfigDom(gristDoc),
|
||||||
cssLabel(t('Number Format')),
|
cssLabel(t('Number Format')),
|
||||||
cssRow(
|
cssRow(
|
||||||
dom.autoDispose(holder),
|
dom.autoDispose(holder),
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
FormSelectConfig
|
FormSelectConfig
|
||||||
} from 'app/client/components/Forms/FormConfig';
|
} from 'app/client/components/Forms/FormConfig';
|
||||||
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
|
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||||
import {TableRec} from 'app/client/models/DocModel';
|
import {TableRec} from 'app/client/models/DocModel';
|
||||||
@ -53,12 +54,12 @@ export class Reference extends NTextBox {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildConfigDom() {
|
public buildConfigDom(gristDoc: GristDoc) {
|
||||||
return [
|
return [
|
||||||
this.buildTransformConfigDom(),
|
this.buildTransformConfigDom(),
|
||||||
dom.create(DropdownConditionConfig, this.field),
|
dom.create(DropdownConditionConfig, this.field, gristDoc),
|
||||||
cssLabel(t('CELL FORMAT')),
|
cssLabel(t('CELL FORMAT')),
|
||||||
super.buildConfigDom(),
|
super.buildConfigDom(gristDoc),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,8 +23,8 @@ export class ReferenceEditor extends NTextEditor {
|
|||||||
constructor(options: FieldOptions) {
|
constructor(options: FieldOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
|
|
||||||
const docData = options.gristDoc.docData;
|
const gristDoc = options.gristDoc;
|
||||||
this._utils = new ReferenceUtils(options.field, docData);
|
this._utils = new ReferenceUtils(options.field, gristDoc);
|
||||||
|
|
||||||
const vcol = this._utils.visibleColModel;
|
const vcol = this._utils.visibleColModel;
|
||||||
this._enableAddNew = (
|
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
|
// 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.
|
// 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 (this.isDisposed()) { return; }
|
||||||
if (needReload && this.textInput.value === '') {
|
if (needReload && this.textInput.value === '') {
|
||||||
this.textInput.value = undef(options.state, options.editValue, this._idToText());
|
this.textInput.value = undef(options.state, options.editValue, this._idToText());
|
||||||
|
@ -55,8 +55,8 @@ export class ReferenceListEditor extends NewBaseEditor {
|
|||||||
constructor(protected options: FieldOptions) {
|
constructor(protected options: FieldOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
|
|
||||||
const docData = options.gristDoc.docData;
|
const gristDoc = options.gristDoc;
|
||||||
this._utils = new ReferenceUtils(options.field, docData);
|
this._utils = new ReferenceUtils(options.field, gristDoc);
|
||||||
|
|
||||||
const vcol = this._utils.visibleColModel;
|
const vcol = this._utils.visibleColModel;
|
||||||
this._enableAddNew = (
|
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
|
// 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.
|
// 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 (this.isDisposed()) { return; }
|
||||||
if (needReload) {
|
if (needReload) {
|
||||||
this._tokenField.setTokens(
|
this._tokenField.setTokens(
|
||||||
|
@ -3,6 +3,7 @@ import {TableDataAction} from 'app/common/DocActions';
|
|||||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||||
import {Role} from 'app/common/roles';
|
import {Role} from 'app/common/roles';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
import {StringUnion} from 'app/common/StringUnion';
|
||||||
|
import {UserInfo} from 'app/common/User';
|
||||||
import {FullUser} from 'app/common/UserAPI';
|
import {FullUser} from 'app/common/UserAPI';
|
||||||
|
|
||||||
// Possible flavors of items in a list of documents.
|
// Possible flavors of items in a list of documents.
|
||||||
@ -75,6 +76,7 @@ export interface OpenLocalDocResult {
|
|||||||
doc: {[tableId: string]: TableDataAction};
|
doc: {[tableId: string]: TableDataAction};
|
||||||
log: MinimalActionGroup[];
|
log: MinimalActionGroup[];
|
||||||
isTimingOn: boolean;
|
isTimingOn: boolean;
|
||||||
|
user: UserInfo;
|
||||||
recoveryMode?: boolean;
|
recoveryMode?: boolean;
|
||||||
userOverride?: UserOverride;
|
userOverride?: UserOverride;
|
||||||
docUsage?: FilteredDocUsageSummary;
|
docUsage?: FilteredDocUsageSummary;
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import {PartialPermissionSet} from 'app/common/ACLPermissions';
|
import {PartialPermissionSet} from 'app/common/ACLPermissions';
|
||||||
import {CellValue, RowRecord} from 'app/common/DocActions';
|
import {CellValue, RowRecord} from 'app/common/DocActions';
|
||||||
import {CompiledPredicateFormula} from 'app/common/PredicateFormula';
|
import {CompiledPredicateFormula} from 'app/common/PredicateFormula';
|
||||||
import {Role} from 'app/common/roles';
|
|
||||||
import {MetaRowRecord} from 'app/common/TableData';
|
import {MetaRowRecord} from 'app/common/TableData';
|
||||||
|
|
||||||
export interface RuleSet {
|
export interface RuleSet {
|
||||||
@ -25,12 +24,6 @@ export interface RulePart {
|
|||||||
memo?: string;
|
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.
|
// As InfoView, but also supporting writing.
|
||||||
export interface InfoEditor {
|
export interface InfoEditor {
|
||||||
get(key: string): CellValue;
|
get(key: string): CellValue;
|
||||||
@ -38,22 +31,6 @@ export interface InfoEditor {
|
|||||||
toJSON(): {[key: string]: any};
|
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 {
|
export interface UserAttributeRule {
|
||||||
origRecord?: RowRecord; // Original record used to create this UserAttributeRule.
|
origRecord?: RowRecord; // Original record used to create this UserAttributeRule.
|
||||||
name: string; // Should be unique among UserAttributeRules.
|
name: string; // Should be unique among UserAttributeRules.
|
||||||
|
@ -10,7 +10,8 @@
|
|||||||
*/
|
*/
|
||||||
import {CellValue, RowRecord} from 'app/common/DocActions';
|
import {CellValue, RowRecord} from 'app/common/DocActions';
|
||||||
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
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 {decodeObject} from 'app/plugin/objtypes';
|
||||||
import constant = require('lodash/constant');
|
import constant = require('lodash/constant');
|
||||||
|
|
||||||
@ -31,11 +32,6 @@ export interface PredicateFormulaInput {
|
|||||||
choice?: string|RowRecord|InfoView;
|
choice?: string|RowRecord|InfoView;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EmptyRecordView implements InfoView {
|
|
||||||
public get(_colId: string): CellValue { return null; }
|
|
||||||
public toJSON() { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The result of compiling ParsedPredicateFormula.
|
* The result of compiling ParsedPredicateFormula.
|
||||||
*/
|
*/
|
||||||
@ -102,7 +98,7 @@ export function compilePredicateFormula(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'dropdown-condition': {
|
case 'dropdown-condition': {
|
||||||
validNames = ['rec', 'choice'];
|
validNames = ['rec', 'choice', 'user'];
|
||||||
break;
|
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());
|
return this._granularAccess.filterDocUsageSummary(docSession, this.getDocUsageSummary());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getUser(docSession: OptDocSession) {
|
||||||
|
return this._granularAccess.getUser(docSession);
|
||||||
|
}
|
||||||
|
|
||||||
public async getUserOverride(docSession: OptDocSession) {
|
public async getUserOverride(docSession: OptDocSession) {
|
||||||
return this._granularAccess.getUserOverride(docSession);
|
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.fetchMetaTables(docSession),
|
||||||
activeDoc.getRecentMinimalActions(docSession),
|
activeDoc.getRecentMinimalActions(docSession),
|
||||||
|
activeDoc.getUser(docSession),
|
||||||
activeDoc.getUserOverride(docSession),
|
activeDoc.getUserOverride(docSession),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -414,6 +415,7 @@ export class DocManager extends EventEmitter {
|
|||||||
doc: metaTables,
|
doc: metaTables,
|
||||||
log: recentActions,
|
log: recentActions,
|
||||||
recoveryMode: activeDoc.recoveryMode,
|
recoveryMode: activeDoc.recoveryMode,
|
||||||
|
user: user.toUserInfo(),
|
||||||
userOverride,
|
userOverride,
|
||||||
docUsage,
|
docUsage,
|
||||||
isTimingOn: activeDoc.isTimingOn,
|
isTimingOn: activeDoc.isTimingOn,
|
||||||
|
@ -26,12 +26,14 @@ import { UserOverride } from 'app/common/DocListAPI';
|
|||||||
import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage';
|
import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage';
|
||||||
import { normalizeEmail } from 'app/common/emails';
|
import { normalizeEmail } from 'app/common/emails';
|
||||||
import { ErrorWithCode } from 'app/common/ErrorWithCode';
|
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 * as gristTypes from 'app/common/gristTypes';
|
||||||
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
|
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 { MetaRowRecord, SingleCell } from 'app/common/TableData';
|
||||||
|
import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView';
|
||||||
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
|
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
|
||||||
|
import { User } from 'app/common/User';
|
||||||
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
||||||
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
||||||
import { GristObjCode } from 'app/plugin/GristData';
|
import { GristObjCode } from 'app/plugin/GristData';
|
||||||
@ -330,11 +332,106 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
this._userAttributesMap = new WeakMap();
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<UserInfo> {
|
public async getCachedUser(docSession: OptDocSession): Promise<User> {
|
||||||
const access = await this._getAccess(docSession);
|
const access = await this._getAccess(docSession);
|
||||||
return access.getUser();
|
return access.getUser();
|
||||||
}
|
}
|
||||||
@ -345,7 +442,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
*/
|
*/
|
||||||
public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
|
public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
|
||||||
return {
|
return {
|
||||||
user: await this._getUser(docSession),
|
user: await this.getUser(docSession),
|
||||||
docId: this._docId
|
docId: this._docId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -479,7 +576,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
public async canApplyBundle() {
|
public async canApplyBundle() {
|
||||||
if (!this._activeBundle) { throw new Error('no active bundle'); }
|
if (!this._activeBundle) { throw new Error('no active bundle'); }
|
||||||
const {docActions, docSession, isDirect} = this._activeBundle;
|
const {docActions, docSession, isDirect} = this._activeBundle;
|
||||||
const currentUser = await this._getUser(docSession);
|
const currentUser = await this.getUser(docSession);
|
||||||
const userIsOwner = await this.isOwner(docSession);
|
const userIsOwner = await this.isOwner(docSession);
|
||||||
if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {
|
if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {
|
||||||
throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules');
|
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> {
|
public async getUserOverride(docSession: OptDocSession): Promise<UserOverride|undefined> {
|
||||||
await this._getUser(docSession);
|
await this.getUser(docSession);
|
||||||
return this._getUserAttributes(docSession).override;
|
return this._getUserAttributes(docSession).override;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1120,7 +1217,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
|
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
|
||||||
const baseAccess = getDocSessionAccess(docSession);
|
const baseAccess = getDocSessionAccess(docSession);
|
||||||
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
|
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
|
||||||
const info = await this._getUser(docSession);
|
const info = await this.getUser(docSession);
|
||||||
return info.Access;
|
return info.Access;
|
||||||
}
|
}
|
||||||
return baseAccess;
|
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.
|
* Get the "View As" user specified in link parameters.
|
||||||
* If aclAsUserId is set, we get the user with the specified id.
|
* 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.
|
* 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.
|
// Owner can modify all comments, without exceptions.
|
||||||
if (userIsOwner) {
|
if (userIsOwner) {
|
||||||
return;
|
return;
|
||||||
@ -2654,7 +2652,7 @@ export class Ruler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RulerOwner {
|
export interface RulerOwner {
|
||||||
getUser(docSession: OptDocSession): Promise<UserInfo>;
|
getUser(docSession: OptDocSession): Promise<User>;
|
||||||
inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
|
inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2688,36 +2686,6 @@ interface ActionCursor {
|
|||||||
// access control state.
|
// 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.
|
* 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)!)];
|
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 {
|
export function validTableIdString(tableId: any): string {
|
||||||
if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); }
|
if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); }
|
||||||
return tableId;
|
return tableId;
|
||||||
|
@ -3,8 +3,9 @@ import { ALL_PERMISSION_PROPS, emptyPermissionSet,
|
|||||||
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
|
MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,
|
||||||
toMixed } from 'app/common/ACLPermissions';
|
toMixed } from 'app/common/ACLPermissions';
|
||||||
import { ACLRuleCollection } from 'app/common/ACLRuleCollection';
|
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 { PredicateFormulaInput } from 'app/common/PredicateFormula';
|
||||||
|
import { User } from 'app/common/User';
|
||||||
import { getSetMapValue } from 'app/common/gutil';
|
import { getSetMapValue } from 'app/common/gutil';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import { mapValues } from 'lodash';
|
import { mapValues } from 'lodash';
|
||||||
@ -80,8 +81,8 @@ abstract class RuleInfo<MixedT extends TableT, TableT> {
|
|||||||
return this._mergeFullAccess(tableAccess);
|
return this._mergeFullAccess(tableAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUser(): UserInfo {
|
public getUser(): User {
|
||||||
return this._input.user!;
|
return this._input.user! as User;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;
|
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 {assert, driver, Key} from 'mocha-webdriver';
|
||||||
import * as gu from 'test/nbrowser/gristUtils';
|
import * as gu from 'test/nbrowser/gristUtils';
|
||||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
||||||
@ -5,22 +6,56 @@ import {setupTestSuite} from 'test/nbrowser/testUtils';
|
|||||||
describe('DropdownConditionEditor', function () {
|
describe('DropdownConditionEditor', function () {
|
||||||
this.timeout(20000);
|
this.timeout(20000);
|
||||||
const cleanup = setupTestSuite();
|
const cleanup = setupTestSuite();
|
||||||
|
let api: UserAPI;
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
const session = await gu.session().login();
|
const session = await gu.session().user('user1').login();
|
||||||
await session.tempDoc(cleanup, 'DropdownCondition.grist');
|
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();
|
await gu.openColumnPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => gu.checkForErrors());
|
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() {
|
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() {
|
it('creates dropdown conditions', async function() {
|
||||||
await gu.getCell(1, 1).click();
|
await gu.getCell(1, 1).click();
|
||||||
assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent());
|
await driver.find('.test-field-dropdown-condition').click();
|
||||||
await driver.find('.test-field-set-dropdown-condition').click();
|
|
||||||
await gu.waitAppFocus(false);
|
await gu.waitAppFocus(false);
|
||||||
await gu.sendKeys('c');
|
await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, 'c');
|
||||||
await gu.waitToPass(async () => {
|
await gu.waitToPass(async () => {
|
||||||
const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText());
|
const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText());
|
||||||
assert.deepEqual(completions, [
|
assert.deepEqual(completions, [
|
||||||
@ -28,6 +63,7 @@ describe('DropdownConditionEditor', function () {
|
|||||||
're\nc\n.Name\n ',
|
're\nc\n.Name\n ',
|
||||||
're\nc\n.Role\n ',
|
're\nc\n.Role\n ',
|
||||||
're\nc\n.Supervisor\n ',
|
're\nc\n.Supervisor\n ',
|
||||||
|
'user.A\nc\ncess\n ',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
await gu.sendKeysSlowly(['hoice not in $']);
|
await gu.sendKeysSlowly(['hoice not in $']);
|
||||||
@ -141,6 +177,11 @@ describe('DropdownConditionEditor', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe(`in reference columns`, 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() {
|
it('creates dropdown conditions', async function() {
|
||||||
await gu.getCell(2, 1).click();
|
await gu.getCell(2, 1).click();
|
||||||
assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent());
|
assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent());
|
||||||
@ -288,4 +329,44 @@ describe('DropdownConditionEditor', function () {
|
|||||||
await gu.sendKeys(Key.ESCAPE);
|
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 {CellValue} from 'app/common/DocActions';
|
||||||
import {InfoView} from 'app/common/GranularAccessClause';
|
|
||||||
import {GristObjCode} from 'app/plugin/GristData';
|
import {GristObjCode} from 'app/plugin/GristData';
|
||||||
import {CompiledPredicateFormula, compilePredicateFormula} from 'app/common/PredicateFormula';
|
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 {makeExceptionalDocSession} from 'app/server/lib/DocSession';
|
||||||
import {User} from 'app/server/lib/GranularAccess';
|
|
||||||
import {assert} from 'chai';
|
import {assert} from 'chai';
|
||||||
import {createDocTools} from 'test/server/docTools';
|
import {createDocTools} from 'test/server/docTools';
|
||||||
import * as testUtils from 'test/server/testUtils';
|
import * as testUtils from 'test/server/testUtils';
|
||||||
|
Loading…
Reference in New Issue
Block a user