mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add dropdown conditions
Summary: Dropdown conditions let you specify a predicate formula that's used to filter choices and references in their respective autocomplete dropdown menus. Test Plan: Python and browser tests (WIP). Reviewers: jarek, paulfitz Reviewed By: jarek Subscribers: dsagal, paulfitz Differential Revision: https://phab.getgrist.com/D4235
This commit is contained in:
@@ -10,7 +10,7 @@ import {Notifier} from 'app/client/models/NotifyModel';
|
||||
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
|
||||
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
|
||||
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
|
||||
import {prefersDarkModeObs} from 'app/client/ui2018/cssVars';
|
||||
import {gristThemePrefs} from 'app/client/ui2018/theme';
|
||||
import {AsyncCreate} from 'app/common/AsyncCreate';
|
||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||
@@ -21,9 +21,7 @@ import {LocalPlugin} from 'app/common/plugin';
|
||||
import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs';
|
||||
import {isOwner, isOwnerOrEditor} from 'app/common/roles';
|
||||
import {getTagManagerScript} from 'app/common/tagManager';
|
||||
import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs,
|
||||
ThemePrefsChecker} from 'app/common/ThemePrefs';
|
||||
import {getThemeColors} from 'app/common/Themes';
|
||||
import {getDefaultThemePrefs, ThemePrefs, ThemePrefsChecker} from 'app/common/ThemePrefs';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {ExtendedUser} from 'app/common/UserAPI';
|
||||
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
||||
@@ -118,7 +116,6 @@ export interface AppModel {
|
||||
|
||||
userPrefsObs: Observable<UserPrefs>;
|
||||
themePrefs: Observable<ThemePrefs>;
|
||||
currentTheme: Computed<Theme>;
|
||||
/**
|
||||
* Popups that user has seen.
|
||||
*/
|
||||
@@ -170,8 +167,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
|
||||
|
||||
constructor(window: {gristConfig?: GristLoadConfig},
|
||||
public readonly api: UserAPI = newUserAPIImpl(),
|
||||
public readonly options: TopAppModelOptions = {}) {
|
||||
public readonly api: UserAPI = newUserAPIImpl(),
|
||||
public readonly options: TopAppModelOptions = {}
|
||||
) {
|
||||
super();
|
||||
setErrorNotifier(this.notifier);
|
||||
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
||||
@@ -307,7 +305,6 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
defaultValue: getDefaultThemePrefs(),
|
||||
checker: ThemePrefsChecker,
|
||||
}) as Observable<ThemePrefs>;
|
||||
public readonly currentTheme = this._getCurrentThemeObs();
|
||||
|
||||
public readonly dismissedPopups = getUserPrefObs(this.userPrefsObs, 'dismissedPopups',
|
||||
{ defaultValue: [] }) as Observable<DismissedPopup[]>;
|
||||
@@ -359,6 +356,11 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
public readonly orgError?: OrgError,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Whenever theme preferences change, update the global `gristThemePrefs` observable; this triggers
|
||||
// an automatic update to the global `gristThemeObs` computed observable.
|
||||
this.autoDispose(subscribe(this.themePrefs, (_use, themePrefs) => gristThemePrefs.set(themePrefs)));
|
||||
|
||||
this._recordSignUpIfIsNewUser();
|
||||
|
||||
const state = urlState().state.get();
|
||||
@@ -493,41 +495,14 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
dataLayer.push({event: 'new-sign-up'});
|
||||
getUserPrefObs(this.userPrefsObs, 'recordSignUpEvent').set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private _getCurrentThemeObs() {
|
||||
return Computed.create(this, this.themePrefs, prefersDarkModeObs(),
|
||||
(_use, themePrefs, prefersDarkMode) => {
|
||||
let {appearance, syncWithOS} = themePrefs;
|
||||
|
||||
const urlParams = urlState().state.get().params;
|
||||
if (urlParams?.themeAppearance) {
|
||||
appearance = urlParams?.themeAppearance;
|
||||
}
|
||||
|
||||
if (urlParams?.themeSyncWithOs !== undefined) {
|
||||
syncWithOS = urlParams?.themeSyncWithOs;
|
||||
}
|
||||
|
||||
if (syncWithOS) {
|
||||
appearance = prefersDarkMode ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
let nameOrColors = themePrefs.colors[appearance];
|
||||
if (urlParams?.themeName) {
|
||||
nameOrColors = urlParams?.themeName;
|
||||
}
|
||||
|
||||
let colors: ThemeColors;
|
||||
if (typeof nameOrColors === 'string') {
|
||||
colors = getThemeColors(nameOrColors);
|
||||
} else {
|
||||
colors = nameOrColors;
|
||||
}
|
||||
|
||||
return {appearance, colors};
|
||||
},
|
||||
);
|
||||
export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
|
||||
if (!org) { return ''; }
|
||||
if (user && user.anonymous && org.owner && org.owner.id === user.id) {
|
||||
return "@Guest";
|
||||
}
|
||||
return getOrgName(org);
|
||||
}
|
||||
|
||||
export function getHomeUrl(): string {
|
||||
@@ -541,11 +516,3 @@ export function newUserAPIImpl(): UserAPIImpl {
|
||||
fetch: hooks.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
|
||||
if (!org) { return ''; }
|
||||
if (user && user.anonymous && org.owner && org.owner.id === user.id) {
|
||||
return "@Guest";
|
||||
}
|
||||
return getOrgName(org);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface ICellItem {
|
||||
cleanText: string; // Trimmed lowercase text for searching.
|
||||
}
|
||||
|
||||
|
||||
export class ColumnACIndexes {
|
||||
private _columnCache = new ColumnCache<ACIndex<ICellItem>>(this._tableData);
|
||||
|
||||
@@ -33,22 +32,28 @@ export class ColumnACIndexes {
|
||||
* getColACIndex() is called for the same column with the the same formatter.
|
||||
*/
|
||||
public getColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {
|
||||
return this._columnCache.getValue(colId, () => this._buildColACIndex(colId, formatter));
|
||||
return this._columnCache.getValue(colId, () => this.buildColACIndex(colId, formatter));
|
||||
}
|
||||
|
||||
private _buildColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {
|
||||
public buildColACIndex(
|
||||
colId: string,
|
||||
formatter: BaseFormatter,
|
||||
filter?: (item: ICellItem) => boolean
|
||||
): ACIndex<ICellItem> {
|
||||
const rowIds = this._tableData.getRowIds();
|
||||
const valColumn = this._tableData.getColValues(colId);
|
||||
if (!valColumn) {
|
||||
throw new UserError(`Invalid column ${this._tableData.tableId}.${colId}`);
|
||||
}
|
||||
const items: ICellItem[] = valColumn.map((val, i) => {
|
||||
const rowId = rowIds[i];
|
||||
const text = formatter.formatAny(val);
|
||||
const cleanText = normalizeText(text);
|
||||
return {rowId, text, cleanText};
|
||||
});
|
||||
items.sort(itemCompare);
|
||||
const items: ICellItem[] = valColumn
|
||||
.map((val, i) => {
|
||||
const rowId = rowIds[i];
|
||||
const text = formatter.formatAny(val);
|
||||
const cleanText = normalizeText(text);
|
||||
return {rowId, text, cleanText};
|
||||
})
|
||||
.filter((item) => filter?.(item) ?? true)
|
||||
.sort(itemCompare);
|
||||
return new ACIndexImpl(items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,30 +14,11 @@ import * as roles from 'app/common/roles';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Document, Organization, Workspace} from 'app/common/UserAPI';
|
||||
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
||||
import moment from 'moment';
|
||||
import flatten = require('lodash/flatten');
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
const DELAY_BEFORE_SPINNER_MS = 500;
|
||||
|
||||
// Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
|
||||
// relative time to now - e.g. 'yesterday', '2 days ago'.
|
||||
export function getTimeFromNow(utcDateISO: string): string {
|
||||
const time = moment.utc(utcDateISO);
|
||||
const now = moment();
|
||||
const diff = now.diff(time, 's');
|
||||
if (diff < 0 && diff > -60) {
|
||||
// If the time appears to be in the future, but less than a minute
|
||||
// in the future, chalk it up to a difference in time
|
||||
// synchronization and don't claim the resource will be changed in
|
||||
// the future. For larger differences, just report them
|
||||
// literally, there's a more serious problem or lack of
|
||||
// synchronization.
|
||||
return now.fromNow();
|
||||
}
|
||||
return time.fromNow();
|
||||
}
|
||||
|
||||
export interface HomeModel {
|
||||
// PageType value, one of the discriminated union values used by AppModel.
|
||||
pageType: "home";
|
||||
@@ -190,7 +171,6 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
||||
localPlugins: _app.topAppModel.plugins,
|
||||
untrustedContentOrigin: _app.topAppModel.getUntrustedContentOrigin()!,
|
||||
clientScope,
|
||||
theme: _app.currentTheme,
|
||||
});
|
||||
const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
|
||||
this.importSources.set(importSources);
|
||||
|
||||
@@ -2,12 +2,15 @@ import {ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRe
|
||||
import {formatterForRec} from 'app/client/models/entities/ColumnRec';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
|
||||
import { HeaderStyle, Style } from 'app/client/models/Styles';
|
||||
import {HeaderStyle, Style} from 'app/client/models/Styles';
|
||||
import {ViewFieldConfig} from 'app/client/models/ViewFieldConfig';
|
||||
import * as UserType from 'app/client/widgets/UserType';
|
||||
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||
import {DropdownCondition, DropdownConditionCompilationResult} from 'app/common/DropdownCondition';
|
||||
import {compilePredicateFormula} from 'app/common/PredicateFormula';
|
||||
import {BaseFormatter} from 'app/common/ValueFormatter';
|
||||
import {createParser} from 'app/common/ValueParser';
|
||||
import {Computed} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
// Represents a page entry in the tree of pages.
|
||||
@@ -106,6 +109,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
|
||||
/** Label in FormView. By default FormView uses label, use this to override it. */
|
||||
question: modelUtil.KoSaveableObservable<string|undefined>;
|
||||
|
||||
dropdownCondition: modelUtil.KoSaveableObservable<DropdownCondition|undefined>;
|
||||
dropdownConditionCompiled: Computed<DropdownConditionCompilationResult|null>;
|
||||
|
||||
createValueParser(): (value: string) => any;
|
||||
|
||||
// Helper which adds/removes/updates field's displayCol to match the formula.
|
||||
@@ -316,4 +322,21 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
|
||||
this.disableModify = this.autoDispose(ko.pureComputed(() => this.column().disableModify()));
|
||||
this.disableEditData = this.autoDispose(ko.pureComputed(() => this.column().disableEditData()));
|
||||
|
||||
this.dropdownCondition = this.widgetOptionsJson.prop('dropdownCondition');
|
||||
this.dropdownConditionCompiled = Computed.create(this, use => {
|
||||
const dropdownCondition = use(this.dropdownCondition);
|
||||
if (!dropdownCondition?.parsed) { return null; }
|
||||
|
||||
try {
|
||||
return {
|
||||
kind: 'success',
|
||||
result: compilePredicateFormula(JSON.parse(dropdownCondition.parsed), {
|
||||
variant: 'dropdown-condition',
|
||||
}),
|
||||
};
|
||||
} catch (e) {
|
||||
return {kind: 'failure', error: e.message};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user