(core) move client code to core

Summary:
This moves all client code to core, and makes minimal fix-ups to
get grist and grist-core to compile correctly.  The client works
in core, but I'm leaving clean-up around the build and bundles to
follow-up.

Test Plan: existing tests pass; server-dev bundle looks sane

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
Paul Fitzpatrick
2020-10-02 11:10:00 -04:00
parent 5d60d51763
commit 1654a2681f
395 changed files with 52651 additions and 47 deletions

View File

@@ -0,0 +1,162 @@
import {reportError, setErrorNotifier} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {Notifier} from 'app/client/models/NotifyModel';
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
import {Features} from 'app/common/Features';
import {GristLoadConfig} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {Computed, Disposable, Observable, subscribe} from 'grainjs';
export {reportError} from 'app/client/models/errors';
export type PageType = "doc" | "home" | "billing" | "welcome";
// TopAppModel is the part of the app model that persists across org and user switches.
export interface TopAppModel {
api: UserAPI;
isSingleOrg: boolean;
productFlavor: ProductFlavor;
currentSubdomain: Observable<string|undefined>;
notifier: Notifier;
// Everything else gets fully rebuilt when the org/user changes. This is to ensure that
// different parts of the code aren't using different users/orgs while the switch is pending.
appObs: Observable<AppModel|null>;
// Reinitialize the app. This is called when org or user changes.
initialize(): void;
// Rebuilds the AppModel and consequently the AppUI, without changing the user or the org.
reload(): void;
}
// AppModel is specific to the currently loaded organization and active user. It gets rebuilt when
// we switch the current organization or the current user.
export interface AppModel {
topAppModel: TopAppModel;
api: UserAPI;
currentUser: FullUser|null;
currentValidUser: FullUser|null; // Like currentUser, but null when anonymous
currentOrg: Organization|null; // null if no access to currentSubdomain
currentOrgName: string; // Our best guess for human-friendly name.
orgError?: OrgError; // If currentOrg is null, the error that caused it.
currentFeatures: Features; // features of the current org's product.
pageType: Observable<PageType>;
notifier: Notifier;
}
export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly isSingleOrg: boolean;
public readonly productFlavor: ProductFlavor;
public readonly currentSubdomain = Computed.create(this, urlState().state, (use, s) => s.org);
public readonly notifier = Notifier.create(this);
public readonly appObs = Observable.create<AppModel|null>(this, null);
constructor(
window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = new UserAPIImpl(getHomeUrl()),
) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
// Initially, and on any change to subdomain, call initialize() to get the full Organization
// and the FullUser to use for it (the user may change when switching orgs).
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
}
public initialize(): void {
this._doInitialize().catch(reportError);
}
// Rebuilds the AppModel and consequently the AppUI, etc, without changing the user or the org.
public reload(): void {
const app = this.appObs.get();
if (app) {
const {currentUser, currentOrg, orgError} = app;
AppModelImpl.create(this.appObs, this, currentUser, currentOrg, orgError);
}
}
private async _doInitialize() {
this.appObs.set(null);
try {
const {user, org, orgError} = await this.api.getSessionActive();
if (this.isDisposed()) { return; }
if (org) {
// Check that our domain matches what the api returns.
const state = urlState().state.get();
if (state.org !== org.domain && org.domain !== null) {
// If not, redirect. This is to allow vanity domains
// to "stick" only if paid for.
await urlState().pushUrl({...state, org: org.domain});
}
if (org.billingAccount && org.billingAccount.product &&
org.billingAccount.product.name === 'suspended') {
this.notifier.createUserError(
'This team site is suspended. Documents can be read, but not modified.',
{actions: ['renew']}
);
}
}
AppModelImpl.create(this.appObs, this, user, org, orgError);
} catch (err) {
// tslint:disable-next-line:no-console
console.log(`getSessionActive() failed: ${err}`);
if (this.isDisposed()) { return; }
AppModelImpl.create(this.appObs, this, null, null, {error: err.message, status: err.status || 500});
}
}
}
export class AppModelImpl extends Disposable implements AppModel {
public readonly api: UserAPI = this.topAppModel.api;
// Compute currentValidUser, turning anonymous into null.
public readonly currentValidUser: FullUser|null =
this.currentUser && !this.currentUser.anonymous ? this.currentUser : null;
// Figure out the org name, or blank if details are unavailable.
public readonly currentOrgName = getOrgNameOrGuest(this.currentOrg, this.currentUser);
public readonly currentFeatures = (this.currentOrg && this.currentOrg.billingAccount) ?
this.currentOrg.billingAccount.product.features : {};
// Get the current PageType from the URL.
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
(use, state) => (state.doc ? "doc" : (state.billing ? "billing" : (state.welcome ? "welcome" : "home"))));
public readonly notifier = this.topAppModel.notifier;
constructor(
public readonly topAppModel: TopAppModel,
public readonly currentUser: FullUser|null,
public readonly currentOrg: Organization|null,
public readonly orgError?: OrgError,
) {
super();
}
}
export function getHomeUrl(): string {
const {host, protocol} = window.location;
const gristConfig: any = (window as any).gristConfig;
return (gristConfig && gristConfig.homeUrl) || `${protocol}//${host}`;
}
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);
}

View File

@@ -0,0 +1,136 @@
var _ = require('underscore');
var ko = require('knockout');
var gutil = require('app/common/gutil');
var dispose = require('../lib/dispose');
var modelUtil = require('./modelUtil');
/**
* BaseRowModel is an observable model for a record (or row) of a data (DataRowModel) or meta
* (MetaRowModel) table. It takes a reference to the containing TableModel, and a list of
* column names, and creates an observable for each field.
* TODO: We need to have a way to dispose RowModels, and have them dispose individual fields,
* which should in turn unsubscribe from various events on disposal. And it all should be tested.
*
*/
function BaseRowModel(tableModel, colNames) {
this._table = tableModel;
this._fields = colNames.slice(0);
this._index = ko.observable(null); // The index in the observable to which it belongs.
this._rowId = null;
// Create a field for everything in `_fields`.
this._fields.forEach(function(colName) {
this._createField(colName);
}, this);
}
dispose.makeDisposable(BaseRowModel);
// This adds the dispatchAction() method to RowModel.
_.extend(BaseRowModel.prototype, modelUtil.ActionDispatcher);
/**
* Returns the rowId to which this RowModel is assigned. This is also normally available as the
* `rowModel.id` observable.
*/
BaseRowModel.prototype.getRowId = function() {
return this._rowId;
};
/**
* Creates a field for colName. This is either a top level observable like this[colName]
* for MetaRowModels or a property field like this[name][prop] for DataRowModels
*/
BaseRowModel.prototype._createField = function(colName) {
this[colName] = modelUtil.addSaveInterface(ko.observable(), v => this._saveField(colName, v));
};
/**
* Helper method to send a user action to save a field of the current row to the server.
*/
BaseRowModel.prototype._saveField = function(colName, value) {
var colValues = {};
colValues[colName] = value;
return this.updateColValues(colValues);
};
/**
* Send an update to the server to update multiple columns for this row.
* @param {Object} colValues: Maps colIds to values.
* @returns {Promise} Resolved when the update succeeds.
*/
BaseRowModel.prototype.updateColValues = function(colValues) {
return this._table.sendTableAction(["UpdateRecord", this._rowId, colValues]);
};
/**
* Assigns the field of this RowModel named by `colName` to its corresponding value.
*/
BaseRowModel.prototype._assignColumn = function(colName) {
throw new Error("Not Implemented");
};
//----------------------------------------------------------------------
/**
* Implements the interface expected by modelUtil.ActionDispatcher. We only implement the
* actions that affect individual rows. Note that BulkUpdateRecord needs to be translated to individual
* UpdateRecords for RowModel to know what to do. Messages not here must be implemented by subclasses.
* Some of these require helper methods defined in subclasses
*/
BaseRowModel.prototype._process_RemoveColumn = function(action, tableId, colId) {
if (!gutil.arrayRemove(this._fields, colId)) {
console.error("RowModel #RemoveColumn %s %s: column not found", tableId, colId);
}
delete this[colId];
};
BaseRowModel.prototype._process_ModifyColumn = function(action, tableId, colId, colInfo) {
// No-op for us, because we don't care about any of the column properties.
};
BaseRowModel.prototype._process_UpdateRecord = function(action, tableId, rowId, columnValues) {
for (var colName in columnValues) {
this._assignColumn(colName);
}
};
BaseRowModel.prototype._process_BulkUpdateRecord = function(action, tableId, rowId, columnValues) {
// We get notified when a BulkUpdateRecord affects us, but since we just update all fields from
// the underlying data, we don't need to find our row in the action.
for (var colName in columnValues) {
this._assignColumn(colName);
}
};
// TODO: if AddColumn messages aren't sent for properties, we will need to find a different
// way to create and set the properties than here
BaseRowModel.prototype._process_AddColumn = function(action, tableId, colId, colInfo) {
this._fields.push(colId);
this._createField(colId);
this._assignColumn(colId);
};
BaseRowModel.prototype._process_RenameColumn = function(action, tableId, oldColId, newColId) {
// handle standard renames differently
if (this._fields.indexOf(newColId) !== -1) {
console.error("RowModel #RenameColumn %s %s %s: already exists", tableId, oldColId, newColId);
}
var index = this._fields.indexOf(oldColId);
if (index === -1) {
console.error("RowModel #RenameColumn %s %s %s: not found", tableId, oldColId, newColId);
}
this._fields[index] = newColId;
// Reuse the old observable, but replace its "save" family of functions.
this[newColId] = this[oldColId];
modelUtil.addSaveInterface(this[newColId], this._saveField.bind(this, newColId));
this._assignColumn(newColId);
delete this[oldColId];
};
module.exports = BaseRowModel;

View File

@@ -0,0 +1,256 @@
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {IFormData} from 'app/client/ui/BillingForm';
import {BillingAPI, BillingAPIImpl, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
import {IBillingCard, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI';
import {FullUser} from 'app/common/LoginSessionAPI';
import {bundleChanges, Computed, Disposable, Observable} from 'grainjs';
import isEqual = require('lodash/isEqual');
import omit = require('lodash/omit');
export interface BillingModel {
readonly error: Observable<string|null>;
// Plans available to the user.
readonly plans: Observable<IBillingPlan[]>;
// Client-friendly version of the IBillingSubscription fetched from the server.
// See ISubscriptionModel for details.
readonly subscription: Observable<ISubscriptionModel|undefined>;
// Payment card fetched from the server.
readonly card: Observable<IBillingCard|null>;
readonly currentSubpage: Computed<BillingSubPage|undefined>;
// The billingTask query param of the url - indicates the current operation, if any.
// See BillingTask in BillingAPI for details.
readonly currentTask: Computed<BillingTask|undefined>;
// The planId of the plan to which the user is in process of signing up.
readonly signupPlanId: Computed<string|undefined>;
// The plan to which the user is in process of signing up.
readonly signupPlan: Computed<IBillingPlan|undefined>;
// Indicates whether the request for billing account information fails with unauthorized.
// Initialized to false until the request is made.
readonly isUnauthorized: Observable<boolean>;
// The tax rate to use for the sign up charge. Initialized by calling fetchSignupTaxRate.
signupTaxRate: number|undefined;
reportBlockingError(this: void, err: Error): void;
// Fetch billing account managers.
fetchManagers(): Promise<FullUser[]>;
// Add billing account manager.
addManager(email: string): Promise<void>;
// Remove billing account manager.
removeManager(email: string): Promise<void>;
// Remove the payment card from the account.
removeCard(): Promise<void>;
// Returns a boolean indicating if the org domain string is available.
isDomainAvailable(domain: string): Promise<boolean>;
// Triggered when submit is clicked on the payment page. Performs the API billing account
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
submitPaymentPage(formData?: IFormData): Promise<void>;
// Fetches the effective tax rate for the address in the given form.
fetchSignupTaxRate(formData: IFormData): Promise<void>;
// Fetches subscription data associated with the given org, if the pages are associated with an
// org and the user is a plan manager. Otherwise, fetches available plans only.
fetchData(forceReload?: boolean): Promise<void>;
}
export interface ISubscriptionModel extends Omit<IBillingSubscription, 'plans'|'card'> {
// The active plan.
activePlan: IBillingPlan;
// The upcoming plan, or null if the current plan is not set to end.
upcomingPlan: IBillingPlan|null;
}
/**
* Creates the model for the BillingPage. See app/client/ui/BillingPage for details.
*/
export class BillingModelImpl extends Disposable implements BillingModel {
public readonly error = Observable.create<string|null>(this, null);
// Plans available to the user.
public readonly plans: Observable<IBillingPlan[]> = Observable.create(this, []);
// Client-friendly version of the IBillingSubscription fetched from the server.
// See ISubscriptionModel for details.
public readonly subscription: Observable<ISubscriptionModel|undefined> = Observable.create(this, undefined);
// Payment card fetched from the server.
public readonly card: Observable<IBillingCard|null> = Observable.create(this, null);
public readonly currentSubpage: Computed<BillingSubPage|undefined> =
Computed.create(this, urlState().state, (use, s) => s.billing === 'billing' ? undefined : s.billing);
// The billingTask query param of the url - indicates the current operation, if any.
// See BillingTask in BillingAPI for details.
public readonly currentTask: Computed<BillingTask|undefined> =
Computed.create(this, urlState().state, (use, s) => s.params && s.params.billingTask);
// The planId of the plan to which the user is in process of signing up.
public readonly signupPlanId: Computed<string|undefined> =
Computed.create(this, urlState().state, (use, s) => s.params && s.params.billingPlan);
// The plan to which the user is in process of signing up.
public readonly signupPlan: Computed<IBillingPlan|undefined> =
Computed.create(this, this.plans, this.signupPlanId, (use, plans, pid) => plans.find(_p => _p.id === pid));
// The tax rate to use for the sign up charge. Initialized by calling fetchSignupTaxRate.
public signupTaxRate: number|undefined;
// Indicates whether the request for billing account information fails with unauthorized.
// Initialized to false until the request is made.
public readonly isUnauthorized: Observable<boolean> = Observable.create(this, false);
public readonly reportBlockingError = this._reportBlockingError.bind(this);
private readonly _billingAPI: BillingAPI = new BillingAPIImpl(getHomeUrl());
constructor(private _appModel: AppModel) {
super();
}
// Fetch billing account managers to initialize the dom.
public async fetchManagers(): Promise<FullUser[]> {
const billingAccount = await this._billingAPI.getBillingAccount();
return billingAccount.managers;
}
public async addManager(email: string): Promise<void> {
await this._billingAPI.updateBillingManagers({
users: {[email]: 'managers'}
});
}
public async removeManager(email: string): Promise<void> {
await this._billingAPI.updateBillingManagers({
users: {[email]: null}
});
}
// Remove the payment card from the account.
public async removeCard(): Promise<void> {
try {
await this._billingAPI.removeCard();
this.card.set(null);
} catch (err) {
reportError(err);
}
}
public isDomainAvailable(domain: string): Promise<boolean> {
return this._billingAPI.isDomainAvailable(domain);
}
public async submitPaymentPage(formData: IFormData = {}): Promise<void> {
const task = this.currentTask.get();
const planId = this.signupPlanId.get();
// TODO: The server should prevent most of the errors in this function from occurring by
// redirecting improper urls.
try {
if (task === 'signUp') {
// Sign up from an unpaid plan to a paid plan.
if (!planId) { throw new Error('BillingPage _submit error: no plan selected'); }
if (!formData.token) { throw new Error('BillingPage _submit error: no card submitted'); }
if (!formData.address) { throw new Error('BillingPage _submit error: no address submitted'); }
if (!formData.settings) { throw new Error('BillingPage _submit error: no settings submitted'); }
const o = await this._billingAPI.signUp(planId, formData.token, formData.address, formData.settings);
if (o && o.domain) {
await urlState().pushUrl({ org: o.domain, billing: 'billing', params: undefined });
} else {
// TODO: show problems nicely
throw new Error('BillingPage _submit error: problem creating new organization');
}
} else {
// Any task after sign up.
if (task === 'updatePlan') {
// Change plan from a paid plan to another paid plan or to the free plan.
if (!planId) { throw new Error('BillingPage _submit error: no plan selected'); }
await this._billingAPI.setSubscription(planId, formData.token);
} else if (task === 'addCard' || task === 'updateCard') {
// Add or update payment card.
if (!formData.token) { throw new Error('BillingPage _submit error: missing card info token'); }
await this._billingAPI.setCard(formData.token);
} else if (task === 'updateAddress') {
const org = this._appModel.currentOrg;
const sub = this.subscription.get();
const name = formData.settings && formData.settings.name;
// Get the values of the new address and settings if they have changed.
const newAddr = sub && !isEqual(formData.address, sub.address) && formData.address;
const newSettings = org && (name !== org.name) && formData.settings;
// If the address or settings have a new value, run the update.
if (newAddr || newSettings) {
await this._billingAPI.updateAddress(newAddr || undefined, newSettings || undefined);
}
// If there is an org update, re-initialize the org in the client.
if (newSettings) { await this._appModel.topAppModel.initialize(); }
} else {
throw new Error('BillingPage _submit error: no task in progress');
}
// Show the billing summary page after submission
await urlState().pushUrl({ billing: 'billing', params: undefined });
}
} catch (err) {
// TODO: These errors may need to be reported differently since they're not user-friendly
reportError(err);
throw err;
}
}
public async fetchSignupTaxRate(formData: IFormData): Promise<void> {
try {
if (this.currentTask.get() !== 'signUp') {
throw new Error('fetchSignupTaxRate only available during signup');
}
if (!formData.address) {
throw new Error('Signup form data must include address');
}
this.signupTaxRate = await this._billingAPI.getTaxRate(formData.address);
} catch (err) {
// TODO: These errors may need to be reported differently since they're not user-friendly
reportError(err);
throw err;
}
}
// If forceReload is set, re-fetches and updates already fetched data.
public async fetchData(forceReload: boolean = false): Promise<void> {
if (this.currentSubpage.get() === 'plans' && !this._appModel.currentOrg) {
// If these are billing sign up pages, fetch the plan options only.
await this._fetchPlans();
} else {
// If these are billing settings pages for an existing org, fetch the subscription data.
await this._fetchSubscription(forceReload);
}
}
private _reportBlockingError(err: Error) {
// TODO billing pages don't instantiate notifications UI (they probably should).
reportError(err);
const details = (err as any).details;
const message = (details && details.userError) || err.message;
this.error.set(message);
}
private async _fetchSubscription(forceReload: boolean = false): Promise<void> {
if (forceReload || this.subscription.get() === undefined) {
try {
const sub = await this._billingAPI.getSubscription();
bundleChanges(() => {
this.plans.set(sub.plans);
const subModel: ISubscriptionModel = {
activePlan: sub.plans[sub.planIndex],
upcomingPlan: sub.upcomingPlanIndex !== sub.planIndex ? sub.plans[sub.upcomingPlanIndex] : null,
...omit(sub, 'plans', 'card'),
};
this.subscription.set(subModel);
this.card.set(sub.card);
// Clear the fetch errors on success.
this.isUnauthorized.set(false);
this.error.set(null);
});
} catch (e) {
if (e.status === 401 || e.status === 403) { this.isUnauthorized.set(true); }
throw e;
}
}
}
// Fetches the plans only - used when the billing pages are not associated with an org.
private async _fetchPlans(): Promise<void> {
if (this.plans.get().length === 0) {
this.plans.set(await this._billingAPI.getPlans());
}
}
}

View File

@@ -0,0 +1,28 @@
import {ColumnGetters} from 'app/common/ColumnGetters';
import * as gristTypes from 'app/common/gristTypes';
/**
*
* An implementation of ColumnGetters for the client, drawing
* on the observables and models available in that context.
*
*/
export class ClientColumnGetters implements ColumnGetters {
constructor(private _tableModel: any) {
}
public getColGetter(colRef: number): ((rowId: number) => any) | null {
const colId = this._tableModel.docModel.columns.getRowModel(Math.abs(colRef)).colId();
return this._tableModel.tableData.getRowPropFunc(colId);
}
public getManualSortGetter(): ((rowId: number) => any) | null {
const manualSortCol = this._tableModel.tableMetaRow.columns().peek().find(
(c: any) => c.colId() === gristTypes.MANUALSORT);
if (!manualSortCol) {
return null;
}
return this.getColGetter(manualSortCol.getRowId());
}
}

View File

@@ -0,0 +1,82 @@
/**
* Implements a cache of ACIndex objects for columns in Grist table.
*
* The getColACIndex() function returns the corresponding ACIndex, building it if needed and
* caching for subsequent calls. Any change to the column or a value in it invalidates the cache.
*
* It is available as tableData.columnACIndexes.
*
* It is currently used for auto-complete in the ReferenceEditor widget.
*/
import {ACIndex, ACIndexImpl} from 'app/client/lib/ACIndex';
import {UserError} from 'app/client/models/errors';
import {TableData} from 'app/client/models/TableData';
import {DocAction} from 'app/common/DocActions';
import {isBulkUpdateRecord, isUpdateRecord} from 'app/common/DocActions';
import {getSetMapValue, nativeCompare} from 'app/common/gutil';
import {BaseFormatter} from 'app/common/ValueFormatter';
export interface ICellItem {
rowId: number|'new';
text: string; // Formatted cell text.
cleanText: string; // Trimmed lowercase text for searching.
}
export class ColumnACIndexes {
private _cachedColIndexes = new Map<string, ACIndex<ICellItem>>();
constructor(private _tableData: TableData) {
// Whenever a table action is applied, consider invalidating per-column caches.
this._tableData.tableActionEmitter.addListener(this._invalidateCache, this);
this._tableData.dataLoadedEmitter.addListener(this._clearCache, this);
}
/**
* Returns the column index for the given column, using a cached one if available.
* The formatter should be created using field.createVisibleColFormatter(). It's assumed that
* getColACIndex() is called for the same column with the the same formatter.
*/
public getColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {
return getSetMapValue(this._cachedColIndexes, colId, () => this._buildColACIndex(colId, formatter));
}
private _buildColACIndex(colId: string, formatter: BaseFormatter): 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 = text.trim().toLowerCase();
return {rowId, text, cleanText};
});
items.sort(itemCompare);
return new ACIndexImpl(items);
}
private _invalidateCache(action: DocAction): void {
if (isUpdateRecord(action) || isBulkUpdateRecord(action)) {
// If the update only affects existing records, only invalidate affected columns.
const colValues = action[3];
for (const colId of Object.keys(colValues)) {
this._cachedColIndexes.delete(colId);
}
} else {
// For add/delete actions and all schema changes, drop the cache entirelly to be on the safe side.
this._clearCache();
}
}
private _clearCache(): void {
this._cachedColIndexes.clear();
}
}
function itemCompare(a: ICellItem, b: ICellItem) {
return nativeCompare(a.cleanText, b.cleanText) ||
nativeCompare(a.text, b.text) ||
nativeCompare(a.rowId, b.rowId);
}

View File

@@ -0,0 +1,109 @@
import {CellValue} from 'app/common/DocActions';
import {nativeCompare} from 'app/common/gutil';
import {Disposable, Observable} from 'grainjs';
export type ColumnFilterFunc = (value: CellValue) => boolean;
interface FilterSpec { // Filter object as stored in the db
included?: CellValue[];
excluded?: CellValue[];
}
// A more efficient representation of filter state for a column than FilterSpec.
interface FilterState {
include: boolean;
values: Set<CellValue>;
}
// Converts a JSON string for a filter to a FilterState
function makeFilterState(filterJson: string): FilterState {
const spec: FilterSpec = (filterJson && JSON.parse(filterJson)) || {};
return {
include: Boolean(spec.included),
values: new Set(spec.included || spec.excluded || []),
};
}
// Returns a filter function for a particular column: the function takes a cell value and returns
// whether it's accepted according to the given FilterState.
function makeFilterFunc({include, values}: FilterState): ColumnFilterFunc {
// NOTE: This logic results in complex values and their stringified JSON representations as equivalent.
// For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same.
// TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.
return (val: CellValue) => (values.has(Array.isArray(val) ? JSON.stringify(val) : val) === include);
}
// Given a JSON string, returns a ColumnFilterFunc
export function getFilterFunc(filterJson: string): ColumnFilterFunc|null {
return filterJson ? makeFilterFunc(makeFilterState(filterJson)) : null;
}
/**
* ColumnFilter implements a custom filter on a column, i.e. a filter that's diverged from what's
* on the server. It has methods to modify the filter state, and exposes a public filterFunc
* observable which gets triggered whenever the filter state changes.
*
* It does NOT listen to changes in the initial JSON, since it's only used when the filter has
* been customized.
*/
export class ColumnFilter extends Disposable {
public readonly filterFunc: Observable<ColumnFilterFunc>;
private _include: boolean;
private _values: Set<CellValue>;
constructor(private _initialFilterJson: string) {
super();
this.filterFunc = Observable.create<ColumnFilterFunc>(this, () => true);
this.setState(_initialFilterJson);
}
public setState(filterJson: string) {
const state = makeFilterState(filterJson);
this._include = state.include;
this._values = state.values;
this._updateState();
}
public includes(val: CellValue): boolean {
return this._values.has(val) === this._include;
}
public add(val: CellValue) {
this._include ? this._values.add(val) : this._values.delete(val);
this._updateState();
}
public delete(val: CellValue) {
this._include ? this._values.delete(val) : this._values.add(val);
this._updateState();
}
public clear() {
this._values.clear();
this._include = true;
this._updateState();
}
public selectAll() {
this._values.clear();
this._include = false;
this._updateState();
}
// For saving the filter value back.
public makeFilterJson(): string {
const values = Array.from(this._values).sort(nativeCompare);
return JSON.stringify(this._include ? {included: values} : {excluded: values});
}
public hasChanged(): boolean {
return this.makeFilterJson() !== this._initialFilterJson;
}
private _updateState(): void {
this.filterFunc.set(makeFilterFunc({include: this._include, values: this._values}));
}
}
export const allInclusive = '{"excluded":[]}';

View File

@@ -0,0 +1,41 @@
/**
* The ConnectStateManager helper class helps maintain the connection state. A disconnect goes
* through multiple stages, to inform the user of long disconnects while minimizing the disruption
* for short ones. This class manages these timings, and triggers ConnectState changes.
*/
import {Disposable, Observable} from 'grainjs';
// Describes the connection state, which is shown as part of the notifications UI.
// See https://grist.quip.com/X92IAHZV3uoo/Notifications
export enum ConnectState { Connected, JustDisconnected, RecentlyDisconnected, ReallyDisconnected }
export class ConnectStateManager extends Disposable {
// On disconnect, ConnectState changes to JustDisconnected. These intervals set how long after
// the disconnect ConnectState should change to other values.
public static timeToRecentlyDisconnected = 5000;
public static timeToReallyDisconnected = 30000;
public readonly connectState = Observable.create<ConnectState>(this, ConnectState.Connected);
private _timers: Array<ReturnType<typeof setTimeout>> = [];
public setConnected(yesNo: boolean) {
if (yesNo) {
this._timers.forEach((t) => clearTimeout(t));
this._timers = [];
this._setState(ConnectState.Connected);
} else if (this.connectState.get() === ConnectState.Connected) {
this._timers = [
setTimeout(() => this._setState(ConnectState.RecentlyDisconnected),
ConnectStateManager.timeToRecentlyDisconnected),
setTimeout(() => this._setState(ConnectState.ReallyDisconnected),
ConnectStateManager.timeToReallyDisconnected),
];
this._setState(ConnectState.JustDisconnected);
}
}
private _setState(state: ConnectState) {
this.connectState.set(state);
}
}

View File

@@ -0,0 +1,107 @@
import { KoArray } from 'app/client/lib/koArray';
import * as koUtil from 'app/client/lib/koUtil';
import * as BaseRowModel from 'app/client/models/BaseRowModel';
import * as DataTableModel from 'app/client/models/DataTableModel';
import { IRowModel } from 'app/client/models/DocModel';
import { ValidationRec } from 'app/client/models/entities/ValidationRec';
import * as modelUtil from 'app/client/models/modelUtil';
import { ColValues } from 'app/common/DocActions';
import * as ko from 'knockout';
/**
* DataRowModel is a RowModel for a Data Table. It creates observables for each field in colNames.
* A DataRowModel is initialized "unassigned", and can be assigned to any rowId using `.assign()`.
*/
export class DataRowModel extends BaseRowModel {
// Instances of this class are indexable, but that is a little awkward to type.
// The cells field gives typed access to that aspect of the instance. This is a
// bit hacky, and should be cleaned up when BaseRowModel is ported to typescript.
public readonly cells: {[key: string]: modelUtil.KoSaveableObservable<any>} = this as any;
public _validationFailures: ko.PureComputed<Array<IRowModel<'_grist_Validations'>>>;
public _isAddRow: ko.Observable<boolean>;
private _allValidationsList: ko.Computed<KoArray<ValidationRec>>;
private _isRealChange: ko.Observable<boolean>;
public constructor(dataTableModel: DataTableModel, colNames: string[]) {
super(dataTableModel, colNames);
this._allValidationsList = dataTableModel.tableMetaRow.validations;
this._isAddRow = ko.observable(false);
// Observable that's set whenever a change to a row model is likely to be real, and unset when a
// row model is being reassigned to a different row. If a widget uses CSS transitions for
// changes, those should only be enabled when _isRealChange is true.
this._isRealChange = ko.observable(true);
this._validationFailures = this.autoDispose(ko.pureComputed(function() {
return this._allValidationsList().all().filter(
validation => !this.cells[this.getValidationNameFromId(validation.id())]());
}, this));
}
/**
* Helper method to get the column id of a validation associated with a given id
* No code other than this should need to know what
* naming scheme is used
*/
public getValidationNameFromId(id: number) {
return "validation___" + id;
}
/**
* Overrides BaseRowModel.updateColValues(), which is used to save fields, to support the special
* "add-row" records, and to ensure values are up-to-date when the action completes.
*/
public async updateColValues(colValues: ColValues) {
const action = this._isAddRow.peek() ?
["AddRecord", null, colValues] : ["UpdateRecord", this._rowId, colValues];
try {
return await this._table.sendTableAction(action);
} finally {
// If the action doesn't actually result in an update to a row, it's important to reset the
// observable to the data (if the data did get updated, this will be a no-op). This is also
// important for AddRecord: if after the update, this row is again the 'new' row, it needs to
// be cleared out.
// TODO: in the case when data reverts because an update didn't happen (e.g. typing in
// "12.000" into a numeric column that has "12" in it), there should be a visual indication.
Object.keys(colValues).forEach(colId => this._assignColumn(colId));
}
}
/**
* Assign the DataRowModel to a different row of the table. This is primarily used with koDomScrolly,
* when scrolling is accomplished by reusing a few rows of DOM and their underying RowModels.
*/
public assign(rowId: number|'new'|null) {
this._rowId = rowId;
this._isAddRow(rowId === 'new');
// When we reassign a row, unset _isRealChange momentarily (to disable CSS transitions).
// NOTE: it would be better to only set this flag when there is a data change (rather than unset
// it whenever we scroll), but Chrome will only run a transition if it's enabled before the
// actual DOM change, so setting this flag in the same tick as a change is not sufficient.
this._isRealChange(false);
// Include a check to avoid using the observable after the row model has been disposed.
setTimeout(() => this.isDisposed() || this._isRealChange(true), 0);
if (this._rowId !== null) {
this._fields.forEach(colName => this._assignColumn(colName));
}
}
/**
* Helper method to assign a particular column of this row to the associated tabledata.
*/
private _assignColumn(colName: string) {
if (!this.isDisposed() && this.hasOwnProperty(colName)) {
const value =
(this._rowId === 'new' || !this._rowId) ? '' : this._table.tableData.getValue(this._rowId, colName);
koUtil.withKoUtils(this.cells[colName]).assign(value);
}
}
}

View File

@@ -0,0 +1,319 @@
var _ = require('underscore');
var assert = require('assert');
var BackboneEvents = require('backbone').Events;
// Common
var gutil = require('app/common/gutil');
// Libraries
var dispose = require('../lib/dispose');
var koArray = require('../lib/koArray');
// Models
var rowset = require('./rowset');
var TableModel = require('./TableModel');
var {DataRowModel} = require('./DataRowModel');
const {TableQuerySets} = require('./QuerySet');
/**
* DataTableModel maintains the model for an arbitrary data table of a Grist document.
*/
function DataTableModel(docModel, tableData, tableMetaRow) {
TableModel.call(this, docModel, tableData);
this.tableMetaRow = tableMetaRow;
this.tableQuerySets = new TableQuerySets(this.tableData);
// New RowModels are created by copying fields from this._newRowModel template. This way we can
// update the template on schema changes in the same way we update individual RowModels.
// Note that tableMetaRow is incomplete when we get a new table, so we don't rely on it here.
var fields = tableData.getColIds();
assert(fields.includes('id'), "Expecting tableData columns to include `id`");
// This row model gets schema actions via rowNotify, and is used as a template for new rows.
this._newRowModel = this.autoDispose(new DataRowModel(this, fields));
// TODO: Disposed rows should be removed from the set.
this._floatingRows = new Set();
// Listen for notifications that affect all rows, and apply them to the template row.
this.listenTo(this, 'rowNotify', function(rows, action) {
// TODO: (Important) Updates which affect a subset of rows should be handled more efficiently
// for _floatingRows.
// Ideally this._floatingRows would be a Map from rowId to RowModel, like in the LazyArrayModel.
if (rows === rowset.ALL) {
this._newRowModel.dispatchAction(action);
this._floatingRows.forEach(row => {
row.dispatchAction(action);
});
} else {
this._floatingRows.forEach(row => {
if (rows.includes(row.getRowId())) { row.dispatchAction(action); }
});
}
});
// TODO: In the future, we may need RowModel to support fields such as SubRecordList, containing
// collections of records from another table (probably using RowGroupings as in MetaTableModel).
// We'll need to pay attention to col.type() for that.
}
dispose.makeDisposable(DataTableModel);
_.extend(DataTableModel.prototype, TableModel.prototype);
/**
* Creates and returns a LazyArrayModel of RowModels for the rows in the given sortedRowSet.
* @param {Function} optRowModelClass: Class to use for a RowModel in place of DataRowModel.
*/
DataTableModel.prototype.createLazyRowsModel = function(sortedRowSet, optRowModelClass) {
var RowModelClass = optRowModelClass || DataRowModel;
var self = this;
return new LazyArrayModel(sortedRowSet, function makeRowModel() {
return new RowModelClass(self, self._newRowModel._fields);
});
};
/**
* Returns a new rowModel created using `optRowModelClass` or default `DataRowModel`.
* It is the caller's responsibility to dispose of the returned rowModel.
*/
DataTableModel.prototype.createFloatingRowModel = function(optRowModelClass) {
var RowModelClass = optRowModelClass || DataRowModel;
var model = new RowModelClass(this, this._newRowModel._fields);
this._floatingRows.add(model);
model.autoDisposeCallback(() => {
this._floatingRows.delete(model);
});
return model;
};
//----------------------------------------------------------------------
/**
* LazyArrayModel inherits from koArray, and stays parallel to sortedRowSet.getKoArray(),
* maintaining RowModels for only *some* items, with nulls for the rest.
*
* It's tailored for use with koDomScrolly.
*
* You must not modify LazyArrayModel, but are free to use non-modifying koArray methods on it.
* It also exposes methods:
* makeItemModel()
* setItemModel(rowModel, index)
* And it takes responsibility for maintaining
* rowModel._index() - An observable equal to the current index of this item in the array.
*
* @param {rowset.SortedRowSet} sortedRowSet: SortedRowSet to mirror.
* @param {Function} makeRowModelFunc: A function that creates and returns a DataRowModel.
*
* @event rowModelNotify(rowModels, action):
* Forwards the action from 'rowNotify' event, but with a list of affected RowModels rather
* than a list of affected rowIds. Only instantiated RowModels are included.
*/
function LazyArrayModel(sortedRowSet, makeRowModelFunc) {
// The underlying koArray contains some rowModels, and nulls for other elements. We keep it in
// sync with rowIdArray. First, initialize a koArray of proper length with all nulls.
koArray.KoArray.call(this, sortedRowSet.getKoArray().peek().map(function(r) { return null; }));
this._rowIdArray = sortedRowSet.getKoArray();
this._makeRowModel = makeRowModelFunc;
this._assignedRowModels = new Map(); // Assigned rowModels by rowId.
this._allRowModels = new Set(); // All instantiated rowModels.
this.autoDispose(this._rowIdArray.subscribe(this._onSpliceChange, this, 'spliceChange'));
this.listenTo(sortedRowSet, 'rowNotify', this.onRowNotify);
// On disposal, dispose each instantiated RowModel.
this.autoDisposeCallback(function() {
for (let r of this._allRowModels) {
// TODO: Ideally, row models should be disposable.
if (typeof r.dispose === 'function') {
r.dispose();
}
}
});
}
/**
* LazyArrayModel inherits from koArray.
*/
LazyArrayModel.prototype = Object.create(koArray.KoArray.prototype);
dispose.makeDisposable(LazyArrayModel);
_.extend(LazyArrayModel.prototype, BackboneEvents);
/**
* Returns a new item model, as needed by setItemModel(). It is the only way for a new item
* model to get instantiated.
*/
LazyArrayModel.prototype.makeItemModel = function() {
var rowModel = this._makeRowModel();
this._allRowModels.add(rowModel);
return rowModel;
};
/**
* Unassigns a given rowModel, removing it from the LazyArrayModel.
* @returns {Boolean} True if rowModel got unset, false if it was already unset.
*/
LazyArrayModel.prototype.unsetItemModel = function(rowModel) {
this.setItemModel(rowModel, null);
};
/**
* Assigns a given rowModel to the given index. If the rowModel was previously assigned to a
* different index, the old index reverts to null. If index is null, unsets the rowModel.
*/
LazyArrayModel.prototype.setItemModel = function(rowModel, index) {
var arr = this.peek();
// Remove the rowModel from its old index in the observable array, and in _assignedRowModels.
var oldIndex = rowModel._index.peek();
if (oldIndex !== null && arr[oldIndex] === rowModel) {
arr[oldIndex] = null;
}
if (rowModel._rowId !== null) {
this._assignedRowModels.delete(rowModel._rowId);
}
// Handles logic to set the rowModel to the given index.
this._setItemModel(rowModel, index);
if (index !== null && arr.length !== 0) {
// Ensure that index is in-range.
index = gutil.clamp(index, 0, arr.length - 1);
// If there is already a model at the destination index, unassign that one.
if (arr[index] !== null && arr[index] !== rowModel) {
this.unsetItemModel(arr[index]);
}
// Add the newly-assigned model in its place in the array and in _assignedRowModels.
arr[index] = rowModel;
this._assignedRowModels.set(rowModel._rowId, rowModel);
}
};
/**
* Assigns a given floating rowModel to the given index.
* If index is null, unsets the floating rowModel.
*/
LazyArrayModel.prototype.setFloatingRowModel = function(rowModel, index) {
this._setItemModel(rowModel, index);
};
/**
* Helper function to assign a given rowModel to the given index. Used by setItemModel
* and setFloatingRowModel. Does not interact with the array, only the model itself.
*/
LazyArrayModel.prototype._setItemModel = function(rowModel, index) {
var arr = this.peek();
if (index === null || arr.length === 0) {
// Unassign the rowModel if index is null or if there is no valid place to assign it to.
rowModel._index(null);
rowModel.assign(null);
} else {
// Otherwise, ensure that index is in-range.
index = gutil.clamp(index, 0, arr.length - 1);
// Assign the rowModel and set its index.
rowModel._index(index);
rowModel.assign(this._rowIdArray.peek()[index]);
}
};
/**
* Called for any updates to rows, including schema changes. This may affect some or all of the
* rows; in the latter case, rows will be the constant rowset.ALL.
*/
LazyArrayModel.prototype.onRowNotify = function(rows, action) {
if (rows === rowset.ALL) {
for (let rowModel of this._allRowModels) {
rowModel.dispatchAction(action);
}
this.trigger('rowModelNotify', this._allRowModels);
} else {
var affectedRowModels = [];
for (let r of rows) {
var rowModel = this._assignedRowModels.get(r);
if (rowModel) {
rowModel.dispatchAction(action);
affectedRowModels.push(rowModel);
}
}
this.trigger('rowModelNotify', affectedRowModels);
}
};
/**
* Internal helper called on any change in the underlying _rowIdArray. We mirror each new rowId
* with a null. Removed rows are unassigned. We also update subsequent indices.
*/
LazyArrayModel.prototype._onSpliceChange = function(splice) {
var numDeleted = splice.deleted.length;
var i, n;
// Unassign deleted models, and leave for the garbage collector to find.
var arr = this.peek();
for (i = splice.start, n = 0; n < numDeleted; i++, n++) {
if (arr[i]) {
this.unsetItemModel(arr[i]);
}
}
// Update indices for other affected elements.
var delta = splice.added - numDeleted;
if (delta !== 0) {
var firstToAdjust = splice.start + numDeleted;
for (let rowModel of this._assignedRowModels.values()) {
var index = rowModel._index.peek();
if (index >= firstToAdjust) {
rowModel._index(index + delta);
}
}
}
// Construct the arguments for the splice call to apply to ourselves.
var newSpliceArgs = new Array(2 + splice.added);
newSpliceArgs[0] = splice.start;
newSpliceArgs[1] = numDeleted;
for (i = 2; i < newSpliceArgs.length; i++) {
newSpliceArgs[i] = null;
}
// Apply the splice to ourselves, inserting nulls for the newly-added items.
this.arraySplice(splice.start, numDeleted, gutil.arrayRepeat(splice.added, null));
};
/**
* Returns the rowId at the given index from the rowIdArray. (Subscribes if called in a computed.)
*/
LazyArrayModel.prototype.getRowId = function(index) {
return this._rowIdArray.at(index);
};
/**
* Returns the index of the given rowId, or -1 if not found. (Does not subscribe to array.)
*/
LazyArrayModel.prototype.getRowIndex = function(rowId) {
return this._rowIdArray.peek().indexOf(rowId);
};
/**
* Returns the index of the given rowId, or -1 if not found. (Subscribes if called in a computed.)
*/
LazyArrayModel.prototype.getRowIndexWithSub = function(rowId) {
return this._rowIdArray.all().indexOf(rowId);
};
/**
* Returns the rowModel for the given rowId.
* Returns undefined when there is no rowModel for the given rowId, which is often the case
* when it is scrolled out of view.
*/
LazyArrayModel.prototype.getRowModel = function(rowId) {
return this._assignedRowModels.get(rowId);
};
module.exports = DataTableModel;

View File

@@ -0,0 +1,196 @@
import * as BaseRowModel from "app/client/models/BaseRowModel";
import * as DataTableModel from 'app/client/models/DataTableModel';
import { DocModel } from 'app/client/models/DocModel';
import { TableRec } from 'app/client/models/entities/TableRec';
import { TableQuerySets } from 'app/client/models/QuerySet';
import { RowGrouping, SortedRowSet } from 'app/client/models/rowset';
import { TableData } from 'app/client/models/TableData';
import { createEmptyTableDelta, TableDelta } from 'app/common/ActionSummary';
import { DisposableWithEvents } from 'app/common/DisposableWithEvents';
import { CellVersions, UserAction } from 'app/common/DocActions';
import { GristObjCode } from "app/common/gristTypes";
import { CellDelta } from 'app/common/TabularDiff';
import { DocStateComparisonDetails } from 'app/common/UserAPI';
import { CellValue } from 'app/plugin/GristData';
/**
*
* A variant of DataTableModel that is aware of a comparison with another version of the table.
* The constructor takes a DataTableModel and DocStateComparisonDetails. We act as a proxy
* for that DataTableModel, with the following changes to tableData:
*
* - a cell changed remotely from A to B is given the value ['X', {parent: A, remote: B}].
* - a cell changed locally from A to B1 and remotely from A to B2 is given the value
* ['X', {parent: A, local: B1, remote: B2}].
* - negative rowIds are served from the remote table.
*
*/
export class DataTableModelWithDiff extends DisposableWithEvents implements DataTableModel {
public docModel: DocModel;
public isLoaded: ko.Observable<boolean>;
public tableData: TableData;
public tableMetaRow: TableRec;
public tableQuerySets: TableQuerySets;
// For viewing purposes (LazyRowsModel), cells should have comparison info, so we will
// forward to a comparison-aware wrapper. Otherwise, the model is left substantially
// unchanged for now.
private _wrappedModel: DataTableModel;
public constructor(public core: DataTableModel, comparison: DocStateComparisonDetails) {
super();
this.tableMetaRow = core.tableMetaRow;
this.tableQuerySets = core.tableQuerySets;
this.docModel = core.docModel;
this.tableData = new TableDataWithDiff(
core.tableData,
comparison.leftChanges.tableDeltas[core.tableData.tableId] || createEmptyTableDelta(),
comparison.rightChanges.tableDeltas[core.tableData.tableId] || createEmptyTableDelta()) as any;
this.isLoaded = core.isLoaded;
this._wrappedModel = new DataTableModel(this.docModel, this.tableData, this.tableMetaRow);
}
public createLazyRowsModel(sortedRowSet: SortedRowSet, optRowModelClass: any) {
return this._wrappedModel.createLazyRowsModel(sortedRowSet, optRowModelClass);
}
public createFloatingRowModel(optRowModelClass: any): BaseRowModel {
return this.core.createFloatingRowModel(optRowModelClass);
}
public fetch(force?: boolean): Promise<void> {
return this.core.fetch(force);
}
public getAllRows(): ReadonlyArray<number> {
// Could add remote rows, but this method isn't used so it doesn't matter.
return this.core.getAllRows();
}
public getRowGrouping(groupByCol: string): RowGrouping<CellValue> {
return this.core.getRowGrouping(groupByCol);
}
public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]> {
return this.core.sendTableActions(actions, optDesc);
}
public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined {
return this.core.sendTableAction(action, optDesc);
}
}
/**
* A variant of TableData that is aware of a comparison with another version of the table.
* TODO: flesh out, just included essential members so far.
*/
export class TableDataWithDiff {
public dataLoadedEmitter: any;
public tableActionEmitter: any;
private _updates: Set<number>;
constructor(public core: TableData, public leftTableDelta: TableDelta, public rightTableDelta: TableDelta) {
this.dataLoadedEmitter = core.dataLoadedEmitter;
this.tableActionEmitter = core.tableActionEmitter;
// Construct the set of all rows updated in either left/local or right/remote.
// Omit any rows that were deleted in the other version, for simplicity.
const leftRemovals = new Set(leftTableDelta.removeRows);
const rightRemovals = new Set(rightTableDelta.removeRows);
this._updates = new Set([
...leftTableDelta.updateRows.filter(r => !rightRemovals.has(r)),
...rightTableDelta.updateRows.filter(r => !leftRemovals.has(r))
]);
}
public getColIds(): string[] {
return this.core.getColIds();
}
public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]> {
return this.core.sendTableActions(actions, optDesc);
}
public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined {
return this.core.sendTableAction(action, optDesc);
}
/**
* Make a variant of getter for a column that calls getValue for rows added remotely,
* or rows with updates.
*/
public getRowPropFunc(colId: string) {
const fn = this.core.getRowPropFunc(colId);
if (!fn) { return fn; }
return (rowId: number|"new") => {
if (rowId !== 'new' && this._updates.has(rowId)) {
return this.getValue(rowId, colId);
}
return (rowId !== 'new' && rowId < 0) ? this.getValue(rowId, colId) : fn(rowId);
};
}
/**
* Intercept requests for updated cells or cells from remote rows.
*/
public getValue(rowId: number, colId: string): CellValue|undefined {
if (this._updates.has(rowId)) {
const left = this.leftTableDelta.columnDeltas[colId]?.[rowId];
const right = this.rightTableDelta.columnDeltas[colId]?.[rowId];
if (left !== undefined && right !== undefined) {
return [GristObjCode.Versions, {
parent: oldValue(left),
local: newValue(left),
remote: newValue(right)
} as CellVersions];
} else if (right !== undefined) {
return [GristObjCode.Versions, {
parent: oldValue(right),
remote: newValue(right)
} as CellVersions];
} else if (left !== undefined) {
return [GristObjCode.Versions, {
parent: oldValue(left),
local: newValue(left)
} as CellVersions];
} else {
// No change in ActionSummary for this cell, but it could be a formula
// column. So we do a crude comparison between the values available.
// We won't be able to do anything useful for conflicts (e.g. to know
// the display text in a reference columnn for the common parent).
// We also won't be able to detect local changes at all.
const parent = this.core.getValue(rowId, colId);
const remote = this.rightTableDelta.finalRowContent?.[rowId]?.[colId];
if (remote !== undefined && JSON.stringify(remote) !== JSON.stringify(parent)) {
return [GristObjCode.Versions, {parent, remote} as CellVersions];
}
return parent;
}
}
if (rowId < 0) {
const value = this.rightTableDelta.finalRowContent?.[-rowId]?.[colId];
// keep row.id consistent with rowId for convenience.
if (colId === 'id') { return - (value as number); }
return value;
}
return this.core.getValue(rowId, colId);
}
public get tableId() { return this.core.tableId; }
}
/**
* Get original value from a cell change, if available.
*/
function oldValue(delta: CellDelta) {
if (delta[0] === '?') { return null; }
return delta[0]?.[0];
}
/**
* Get new value from a cell change, if available.
*/
function newValue(delta: CellDelta) {
if (delta[1] === '?') { return null; }
return delta[1]?.[0];
}

View File

@@ -0,0 +1,185 @@
/**
* DocData maintains all underlying data for a Grist document, knows how to load it,
* subscribes to actions which change it, and forwards those actions to individual tables.
* It also provides the interface to apply actions to data.
*/
import {DocComm} from 'app/client/components/DocComm';
import {TableData} from 'app/client/models/TableData';
import {ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI';
import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
import {DocData as BaseDocData} from 'app/common/DocData';
import {ColTypeMap} from 'app/common/TableData';
import * as bluebird from 'bluebird';
import {Emitter} from 'grainjs';
import defaults = require('lodash/defaults');
const gristNotify = (window as any).gristNotify;
type BundleCallback = (action: UserAction) => boolean;
export class DocData extends BaseDocData {
public readonly sendActionsEmitter = new Emitter();
public readonly sendActionsDoneEmitter = new Emitter();
// Action verification callback to avoid undesired bundling. Also an indicator that actions are
// currently being bundled.
private _bundleCallback?: BundleCallback|null = null;
private _nextDesc: string|null = null; // The description for the next incoming action.
private _lastActionNum: number|null = null; // ActionNum of the last action in the current bundle, or null.
private _bundleSender: BundleSender;
/**
* Constructor for DocData.
* @param {Object} docComm: A map of server methods availble on this document.
* @param {Object} metaTableData: A map from tableId to table data, presented as an action,
* equivalent to BulkAddRecord, i.e. ["TableData", tableId, rowIds, columnValues].
*/
constructor(public readonly docComm: DocComm, metaTableData: {[tableId: string]: TableDataAction}) {
super((tableId) => docComm.fetchTable(tableId), metaTableData);
this._bundleSender = new BundleSender(this.docComm);
}
public createTableData(tableId: string, tableData: TableDataAction|null, colTypes: ColTypeMap): TableData {
return new TableData(this, tableId, tableData, colTypes);
}
// Version of inherited getTable() which returns the enhance TableData type.
public getTable(tableId: string): TableData|undefined {
return super.getTable(tableId) as TableData;
}
/**
* Finds up to n most likely target columns for the given values in the document.
*/
public async findColFromValues(values: any[], n: number, optTableId?: string): Promise<number[]> {
try {
return await this.docComm.findColFromValues(values, n, optTableId);
} catch (e) {
gristNotify(`Error finding matching columns: ${e.message}`);
return [];
}
}
/**
* Returns error message (traceback) for one invalid formula cell.
*/
public getFormulaError(tableId: string, colId: string, rowId: number): Promise<CellValue> {
return this.docComm.getFormulaError(tableId, colId, rowId);
}
// Sets a bundle to collect all incoming actions. Throws an error if any actions which
// do not match the verification callback are sent.
public startBundlingActions(desc: string|null, callback: BundleCallback) {
this._nextDesc = desc;
this._lastActionNum = null;
this._bundleCallback = callback;
}
// Ends the active bundle collecting all incoming actions.
public stopBundlingActions() {
this._bundleCallback = null;
}
// Execute a callback that may send multiple actions, and bundle those actions together. The
// callback may return a promise, in which case bundleActions() will wait for it to resolve.
public async bundleActions<T>(desc: string|null, callback: () => T|Promise<T>): Promise<T> {
this.startBundlingActions(desc, () => true);
try {
return await callback();
} finally {
this.stopBundlingActions();
}
}
/**
* Sends actions to the server to be applied.
* @param {String} optDesc: Optional description of the actions to be shown in the log.
*
* sendActions also emits two events:
* 'sendActions': emitted before the action is sent, with { actions } object as data.
* 'sendActionsDone': emitted on success, with the same data object.
* Note that it allows a handler for 'sendActions' to pass along information to the handler
* for the corresponding 'sendActionsDone', by tacking it onto the event data object.
*/
public sendActions(actions: UserAction[], optDesc?: string): Promise<any[]> {
// Some old code relies on this promise being a bluebird Promise.
// TODO Remove bluebird and this cast.
return bluebird.Promise.resolve(this._sendActionsImpl(actions, optDesc)) as any;
}
/**
* Sends a single action to the server to be applied. Calls this.sendActions to manage the
* optional bundle.
* @param {String} optDesc: Optional description of the actions to be shown in the log.
*/
public sendAction(action: UserAction, optDesc?: string): Promise<any> {
return this.sendActions([action], optDesc).then((retValues) => retValues[0]);
}
// See documentation of sendActions().
private async _sendActionsImpl(actions: UserAction[], optDesc?: string): Promise<any[]> {
const eventData = {actions};
this.sendActionsEmitter.emit(eventData);
const options = { desc: optDesc };
const bundleCallback = this._bundleCallback;
if (bundleCallback) {
actions.forEach(action => {
if (!bundleCallback(action)) {
gristNotify(`Attempted to add invalid action to current bundle: ${action}.`);
}
});
defaults(options, {
desc: this._nextDesc,
linkId: this._lastActionNum,
});
this._nextDesc = null;
}
const result: ApplyUAResult = await this._bundleSender.applyUserActions(actions, options);
this._lastActionNum = result.actionNum;
this.sendActionsDoneEmitter.emit(eventData);
return result.retValues;
}
}
/**
* BundleSender helper class collects multiple applyUserActions() calls that happen on the same
* tick, and sends them to the server all at once.
*/
class BundleSender {
private _options = {};
private _actions: UserAction[] = [];
private _sendPromise?: Promise<ApplyUAResult>;
constructor(private _docComm: DocComm) {}
public applyUserActions(actions: UserAction[], options: ApplyUAOptions): Promise<ApplyUAResult> {
defaults(this._options, options);
const start = this._actions.length;
this._actions.push(...actions);
const end = this._actions.length;
return this._getSendPromise()
.then(result => ({
actionNum: result.actionNum,
retValues: result.retValues.slice(start, end),
isModification: result.isModification
}));
}
public _getSendPromise(): Promise<ApplyUAResult> {
if (!this._sendPromise) {
// Note that the first Promise.resolve() ensures that the next step (actual send) happens on
// the next tick. By that time, more actions may have been added to this._actions array.
this._sendPromise = Promise.resolve()
.then(() => {
this._sendPromise = undefined;
const ret = this._docComm.applyUserActions(this._actions, this._options);
this._options = {};
this._actions = [];
return ret;
});
}
return this._sendPromise;
}
}

View File

@@ -0,0 +1,118 @@
var koArray = require('../lib/koArray');
var dispose = require('../lib/dispose');
var _ = require('underscore');
var BackboneEvents = require('backbone').Events;
var {pageHasDocList} = require('app/common/urlUtils');
/**
* Constructor for DocListModel
* @param {Object} comm: A map of server methods availble on this document.
*/
function DocListModel(app) {
this.app = app;
this.comm = this.app.comm;
this.docs = koArray();
this.docInvites = koArray();
if (pageHasDocList()) {
this.listenTo(this.comm, 'docListAction', this.docListActionHandler);
this.listenTo(this.comm, 'receiveInvites', () => this.refreshDocList());
// Initialize the DocListModel
this.refreshDocList();
} else {
console.log("Page has no DocList support");
}
}
dispose.makeDisposable(DocListModel);
_.extend(DocListModel.prototype, BackboneEvents);
/**
* Rebuilds DocListModel with a direct call to the server.
*/
DocListModel.prototype.refreshDocList = function() {
return this.comm.getDocList()
.then(docListObj => {
this.docs.assign(docListObj.docs);
this.docInvites.assign(docListObj.docInvites);
})
.catch((err) => {
console.error('Failed to load DocListModel: %s', err);
});
};
/**
* Updates the DocListModel docs and docInvites arrays in response to docListAction events
* @param {Object} message: A docListAction message received from the server.
*/
DocListModel.prototype.docListActionHandler = function(message) {
console.log('docListActionHandler message', message);
if (message && message.data) {
_.each(message.data.addDocs, this.addDoc, this);
_.each(message.data.removeDocs, this.removeDoc, this);
_.each(message.data.changeDocs, this.changeDoc, this);
_.each(message.data.addInvites, this.addInvite, this);
_.each(message.data.removeInvites, this.removeInvite, this);
// DocListModel can ignore rename events since renames also broadcast add/remove events.
} else {
console.error('Unrecognized message', message);
}
};
DocListModel.prototype._removeAtIndex = function(collection, index) {
collection.splice(index, 1);
};
DocListModel.prototype._removeItem = function(collection, name) {
var index = this._findItem(collection, name);
if (index !== -1) {
this._removeAtIndex(collection, index);
}
};
// Binary search is disabled in _.indexOf because the docs may not be sorted by name.
DocListModel.prototype._findItem = function(collection, name) {
var matchIndex = _.indexOf(collection.all().map(item => item.name), name, false);
if (matchIndex === -1) {
console.error('DocListModel does not contain name:', name);
}
return matchIndex;
};
DocListModel.prototype.removeDoc = function(name) {
this._removeItem(this.docs, name);
};
// TODO: removeInvite is unused
DocListModel.prototype.removeInvite = function(name) {
this._removeItem(this.docInvites, name);
};
DocListModel.prototype._addItem = function(collection, fileObj) {
var insertIndex = _.sortedIndex(collection.all(), fileObj, 'name');
this._addItemAtIndex(collection, insertIndex, fileObj);
};
DocListModel.prototype._addItemAtIndex = function(collection, index, fileObj) {
collection.splice(index, 0, fileObj);
};
DocListModel.prototype.addDoc = function(fileObj) {
this._addItem(this.docs, fileObj);
};
// TODO: addInvite is unused
DocListModel.prototype.addInvite = function(fileObj) {
this._addItem(this.docInvites, fileObj);
};
// Called when the metadata for a doc changes.
DocListModel.prototype.changeDoc = function(fileObj) {
let idx = this._findItem(this.docs, fileObj.name);
if (idx !== -1) {
this._removeAtIndex(this.docs, idx);
this._addItem(this.docs, fileObj);
}
};
module.exports = DocListModel;

View File

@@ -0,0 +1,217 @@
/**
* DocModel describes the observable models for all document data, including the built-in tables
* (aka metatables), which are used in the Grist application itself (e.g. to render views).
*
* Since all data is structured as tables, we have several levels of models:
* (1) DocModel maintains all tables
* (2) MetaTableModel maintains data for a built-in table.
* (3) DataTableModel maintains data for a user-defined table.
* (4) RowModels (defined in {Data,Meta}TableModel.js) maintains data for one record in a table.
* For built-in tables, the records are defined in this module, below.
*/
import {KoArray} from 'app/client/lib/koArray';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import * as ko from 'knockout';
import * as koArray from 'app/client/lib/koArray';
import * as koUtil from 'app/client/lib/koUtil';
import * as DataTableModel from 'app/client/models/DataTableModel';
import {DocData} from 'app/client/models/DocData';
import * as MetaRowModel from 'app/client/models/MetaRowModel';
import * as MetaTableModel from 'app/client/models/MetaTableModel';
import * as rowset from 'app/client/models/rowset';
import {RowId} from 'app/client/models/rowset';
import {schema, SchemaTypes} from 'app/common/schema';
import {ACLMembershipRec, createACLMembershipRec} from 'app/client/models/entities/ACLMembershipRec';
import {ACLPrincipalRec, createACLPrincipalRec} from 'app/client/models/entities/ACLPrincipalRec';
import {ACLResourceRec, createACLResourceRec} from 'app/client/models/entities/ACLResourceRec';
import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec';
import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec';
import {createPageRec, PageRec} from 'app/client/models/entities/PageRec';
import {createREPLRec, REPLRec} from 'app/client/models/entities/REPLRec';
import {createTabBarRec, TabBarRec} from 'app/client/models/entities/TabBarRec';
import {createTableRec, TableRec} from 'app/client/models/entities/TableRec';
import {createTableViewRec, TableViewRec} from 'app/client/models/entities/TableViewRec';
import {createValidationRec, ValidationRec} from 'app/client/models/entities/ValidationRec';
import {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
// Re-export all the entity types available. The recommended usage is like this:
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
export {ACLMembershipRec} from 'app/client/models/entities/ACLMembershipRec';
export {ACLPrincipalRec} from 'app/client/models/entities/ACLPrincipalRec';
export {ColumnRec} from 'app/client/models/entities/ColumnRec';
export {DocInfoRec} from 'app/client/models/entities/DocInfoRec';
export {PageRec} from 'app/client/models/entities/PageRec';
export {REPLRec} from 'app/client/models/entities/REPLRec';
export {TabBarRec} from 'app/client/models/entities/TabBarRec';
export {TableRec} from 'app/client/models/entities/TableRec';
export {TableViewRec} from 'app/client/models/entities/TableViewRec';
export {ValidationRec} from 'app/client/models/entities/ValidationRec';
export {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
export {ViewRec} from 'app/client/models/entities/ViewRec';
export {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
/**
* Creates the type for a MetaRowModel containing a KoSaveableObservable for each field listed in
* the auto-generated app/common/schema.ts. It represents the metadata record in the database.
* Particular DocModel entities derive from this, and add other helpful computed values.
*/
export type IRowModel<TName extends keyof SchemaTypes> = MetaRowModel & {
[ColId in keyof SchemaTypes[TName]]: KoSaveableObservable<SchemaTypes[TName][ColId]>;
};
/**
* Returns an observable for an observable array of records from the given table.
*
* @param {RowModel} rowModel: RowModel that owns this recordSet.
* @param {TableModel} tableModel: The model for the table to return records from.
* @param {String} groupByField: The name of the field in the other table by which to group. The
* returned observable arrays will be for the group matching the value of rowModel.id().
* @param {String} [options.sortBy]: Keep the returned array sorted by this key. If omitted, the
* returned array will be sorted by rowId.
*/
export function recordSet<TRow extends MetaRowModel>(
rowModel: MetaRowModel, tableModel: MetaTableModel<TRow>, groupByField: string, options?: {sortBy: string}
): ko.Computed<KoArray<TRow>> {
const opts = {groupBy: groupByField, sortBy: 'id', ...options};
return koUtil.computedAutoDispose(
() => tableModel.createRowGroupModel(rowModel.id() || 0, opts),
null, { pure: true });
}
/**
* Returns an observable for a record from another table, selected using the passed-in observable
* for a rowId. If rowId is invalid, returns the row model for the fake empty record.
* @param {TableModel} tableModel: The model for the table to return a record from.
* @param {ko.observable} rowIdObs: An observable for the row id to look up.
*/
export function refRecord<TRow extends MetaRowModel>(
tableModel: MetaTableModel<TRow>, rowIdObs: ko.Observable<number>|ko.Computed<number>
): ko.Computed<TRow> {
// Pass 'true' to getRowModel() to depend on the row version.
return ko.pureComputed(() => tableModel.getRowModel(rowIdObs() || 0, true));
}
// Use an alias for brevity.
type MTM<RowModel extends MetaRowModel> = MetaTableModel<RowModel>;
export class DocModel {
// MTM is a shorthand for MetaTableModel below, to keep each item to one line.
public docInfo: MTM<DocInfoRec> = this._metaTableModel("_grist_DocInfo", createDocInfoRec);
public tables: MTM<TableRec> = this._metaTableModel("_grist_Tables", createTableRec);
public columns: MTM<ColumnRec> = this._metaTableModel("_grist_Tables_column", createColumnRec);
public views: MTM<ViewRec> = this._metaTableModel("_grist_Views", createViewRec);
public viewSections: MTM<ViewSectionRec> = this._metaTableModel("_grist_Views_section", createViewSectionRec);
public viewFields: MTM<ViewFieldRec> = this._metaTableModel("_grist_Views_section_field", createViewFieldRec);
public tableViews: MTM<TableViewRec> = this._metaTableModel("_grist_TableViews", createTableViewRec);
public tabBar: MTM<TabBarRec> = this._metaTableModel("_grist_TabBar", createTabBarRec);
public validations: MTM<ValidationRec> = this._metaTableModel("_grist_Validations", createValidationRec);
public replHist: MTM<REPLRec> = this._metaTableModel("_grist_REPL_Hist", createREPLRec);
public aclPrincipals: MTM<ACLPrincipalRec> = this._metaTableModel("_grist_ACLPrincipals", createACLPrincipalRec);
public aclMemberships: MTM<ACLMembershipRec> = this._metaTableModel("_grist_ACLMemberships", createACLMembershipRec);
public aclResources: MTM<ACLResourceRec> = this._metaTableModel("_grist_ACLResources", createACLResourceRec);
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
public allTables: KoArray<TableRec>;
public allTableIds: KoArray<string>;
// A mapping from tableId to DataTableModel for user-defined tables.
public dataTables: {[tableId: string]: DataTableModel} = {};
// Another map, this one mapping tableRef (rowId) to DataTableModel.
public dataTablesByRef = new Map<number, DataTableModel>();
public allTabs: KoArray<TabBarRec> = this.tabBar.createAllRowsModel('tabPos');
public allDocPages: KoArray<PageRec> = this.pages.createAllRowsModel('pagePos');
// Flag for tracking whether document is in formula-editing mode
public editingFormula: ko.Observable<boolean> = ko.observable(false);
// List of all the metadata tables.
private _metaTables: Array<MetaTableModel<any>>;
constructor(public readonly docData: DocData) {
// For all the metadata tables, load their data (and create the RowModels).
for (const model of this._metaTables) {
model.loadData();
}
// An observable array of user-visible tables, sorted by tableId, excluding summary tables.
// This is a publicly exposed member.
this.allTables = createUserTablesArray(this.tables);
// An observable array of user-visible tableIds. A shortcut mapped from allTables.
const allTableIds = ko.computed(() => this.allTables.all().map(t => t.tableId()));
this.allTableIds = koArray.syncedKoArray(allTableIds);
// Create an observable array of RowModels for all the data tables. We'll trigger
// onAddTable/onRemoveTable in response to this array's splice events below.
const allTableMetaRows = this.tables.createAllRowsModel('id');
// For a new table, we get AddTable action followed by metadata actions to add a table record
// (which triggers this subscribeForEach) and to add all the column records. So we have to keep
// in mind that metadata for columns isn't available yet.
allTableMetaRows.subscribeForEach({
add: r => this._onAddTable(r),
remove: r => this._onRemoveTable(r),
});
}
private _metaTableModel<TName extends keyof SchemaTypes, TRow extends IRowModel<TName>>(
tableId: TName,
rowConstructor: (this: TRow, docModel: DocModel) => void,
): MetaTableModel<TRow> {
const fields = Object.keys(schema[tableId]);
const model = new MetaTableModel<TRow>(this, this.docData.getTable(tableId)!, fields, rowConstructor);
// To keep _metaTables private member listed after public ones, initialize it on first use.
if (!this._metaTables) { this._metaTables = []; }
this._metaTables.push(model);
return model;
}
private _onAddTable(tableMetaRow: TableRec) {
let tid = tableMetaRow.tableId();
const dtm = new DataTableModel(this, this.docData.getTable(tid)!, tableMetaRow);
this.dataTables[tid] = dtm;
this.dataTablesByRef.set(tableMetaRow.getRowId(), dtm);
// Subscribe to tableMetaRow.tableId() to handle table renames.
tableMetaRow.tableId.subscribe(newTableId => {
this.dataTables[newTableId] = this.dataTables[tid];
delete this.dataTables[tid];
tid = newTableId;
});
}
private _onRemoveTable(tableMetaRow: TableRec) {
const tid = tableMetaRow.tableId();
this.dataTables[tid].dispose();
delete this.dataTables[tid];
this.dataTablesByRef.delete(tableMetaRow.getRowId());
}
}
/**
* Helper to create an observable array of tables, sorted by tableId, and excluding summary
* tables.
*/
function createUserTablesArray(tablesModel: MetaTableModel<TableRec>): KoArray<TableRec> {
// Create a rowSource that filters out table records with non-zero summarySourceTable
// and GristHidden tables for import.
const tableIdGetter = tablesModel.tableData.getRowPropFunc('tableId') as (r: RowId) => string;
const sumTableGetter = tablesModel.tableData.getRowPropFunc('summarySourceTable') as (r: RowId) => number;
const rowSource = new rowset.FilteredRowSource(r => (sumTableGetter(r) === 0 &&
!tableIdGetter(r).startsWith('GristHidden')));
rowSource.subscribeTo(tablesModel);
// Create an observable RowModel array based on this rowSource, sorted by tableId.
return tablesModel._createRowSetModel(rowSource, 'tableId');
}

View File

@@ -0,0 +1,338 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {IUndoState} from 'app/client/components/UndoStack';
import {loadGristDoc} from 'app/client/lib/imports';
import {AppModel, getOrgNameOrGuest, reportError} from 'app/client/models/AppModel';
import {getDoc} from 'app/client/models/gristConfigCache';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
import {App} from 'app/client/ui/App';
import {cssLeftPanel, cssScrollPane} from 'app/client/ui/LeftPanelCommon';
import {buildPagesDom} from 'app/client/ui/Pages';
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
import {tools} from 'app/client/ui/Tools';
import {testId} from 'app/client/ui2018/cssVars';
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
import {delay} from 'app/common/delay';
import {OpenDocMode} from 'app/common/DocListAPI';
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
import {getReconnectTimeout} from 'app/common/gutil';
import {canEdit} from 'app/common/roles';
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
import {Holder, Observable, subscribe} from 'grainjs';
// tslint:disable:no-console
export interface DocInfo extends Document {
isReadonly: boolean;
isSample: boolean;
isPreFork: boolean;
isFork: boolean;
isBareFork: boolean; // a document created without logging in, which is treated as a
// fork without an original.
idParts: UrlIdParts;
openMode: OpenDocMode;
}
export interface DocPageModel {
pageType: "doc";
appModel: AppModel;
currentDoc: Observable<DocInfo|null>;
// This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.
currentDocId: Observable<string|undefined>;
currentWorkspace: Observable<Workspace|null>;
// We may be given information about the org, because of our access to the doc, that
// we can't get otherwise.
currentOrg: Observable<Organization|null>;
currentOrgName: Observable<string>;
currentDocTitle: Observable<string>;
isReadonly: Observable<boolean>;
isPrefork: Observable<boolean>;
isFork: Observable<boolean>;
isBareFork: Observable<boolean>;
isSample: Observable<boolean>;
importSources: ImportSource[];
undoState: Observable<IUndoState|null>; // See UndoStack for details.
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
renameDoc(value: string): Promise<void>;
updateCurrentDoc(urlId: string, openMode: OpenDocMode): Promise<Document>;
refreshCurrentDoc(doc: DocInfo): Promise<Document>;
}
export interface ImportSource {
label: string;
action: () => void;
}
export class DocPageModelImpl extends Disposable implements DocPageModel {
public readonly pageType = "doc";
public readonly currentDoc = Observable.create<DocInfo|null>(this, null);
public readonly currentUrlId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.urlId : undefined);
public readonly currentDocId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.id : undefined);
public readonly currentWorkspace = Computed.create(this, this.currentDoc, (use, doc) => doc && doc.workspace);
public readonly currentOrg = Computed.create(this, this.currentWorkspace, (use, ws) => ws && ws.org);
public readonly currentOrgName = Computed.create(this, this.currentOrg,
(use, org) => getOrgNameOrGuest(org, this.appModel.currentUser));
public readonly currentDocTitle = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.name : '');
public readonly isReadonly = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isReadonly : false);
public readonly isPrefork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isPreFork : false);
public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);
public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);
public readonly isSample = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSample : false);
public readonly importSources: ImportSource[] = [];
// Contains observables indicating whether undo/redo are disabled. See UndoStack for details.
public readonly undoState: Observable<IUndoState|null> = Observable.create(this, null);
// Observable set to the instance of GristDoc once it's created.
public readonly gristDoc = Observable.create<GristDoc|null>(this, null);
// Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the
// URL, and when it changes, we need to re-open.
// If making a comparison, the id of the document we are comparing with is also included
// in the openerDocKey.
private _openerDocKey: string = "";
// Holds a FlowRunner for _openDoc, which is essentially a cancellable promise. It gets replaced
// (with the previous promise cancelled) when _openerDocKey changes.
private _openerHolder = Holder.create<FlowRunner>(this);
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
super();
this.autoDispose(subscribe(urlState().state, (use, state) => {
const urlId = state.doc;
const urlOpenMode = state.mode || 'default';
const docKey = this._getDocKey(state);
if (docKey !== this._openerDocKey) {
this._openerDocKey = docKey;
this.gristDoc.set(null);
this.currentDoc.set(null);
this.undoState.set(null);
if (!urlId) {
this._openerHolder.clear();
} else {
FlowRunner.create(this._openerHolder, (flow: AsyncFlow) => this._openDoc(flow, urlId, urlOpenMode,
state.params?.compare))
.resultPromise.catch(err => this._onOpenError(err));
}
}
}));
}
public createLeftPane(leftPanelOpen: Observable<boolean>) {
return cssLeftPanel(
dom.maybe(this.gristDoc, (activeDoc) => [
addNewButton(leftPanelOpen,
menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), {
placement: 'bottom-start',
// "Add New" menu should have the same width as the "Add New" button that opens it.
stretchToSelector: `.${cssAddNewButton.className}`
}),
testId('dp-add-new')
),
cssScrollPane(
dom.create(buildPagesDom, activeDoc, leftPanelOpen),
dom.create(tools, activeDoc, leftPanelOpen),
)
]),
);
}
public async renameDoc(value: string): Promise<void> {
// The docId should never be unset when this option is available.
const doc = this.currentDoc.get();
if (doc) {
if (value.length > 0) {
await this._api.renameDoc(doc.id, value).catch(reportError);
const newDoc = await this.refreshCurrentDoc(doc);
// a "slug" component of the URL may change when the document name is changed.
await urlState().pushUrl({...urlState().state.get(), ...docUrl(newDoc)}, {replace: true, avoidReload: true});
} else {
// This error won't be shown to user (caught by editableLabel).
throw new Error(`doc name should not be empty`);
}
}
}
public async updateCurrentDoc(urlId: string, openMode: OpenDocMode) {
// TODO It would be bad if a new doc gets opened while this getDoc() is pending...
const newDoc = await getDoc(this._api, urlId);
this.currentDoc.set(buildDocInfo(newDoc, openMode));
return newDoc;
}
public async refreshCurrentDoc(doc: DocInfo) {
return this.updateCurrentDoc(doc.urlId || doc.id, doc.openMode);
}
// Replace the URL without reloading the doc.
public updateUrlNoReload(urlId: string, urlOpenMode: OpenDocMode, options: {replace: boolean}) {
const state = urlState().state.get();
const nextState = {...state, doc: urlId, mode: urlOpenMode === 'default' ? undefined : urlOpenMode};
// We preemptively update _openerDocKey so that the URL update doesn't trigger a reload.
this._openerDocKey = this._getDocKey(nextState);
return urlState().pushUrl(nextState, {avoidReload: true, ...options});
}
private _onOpenError(err: Error) {
if (err instanceof CancelledError) {
// This means that we started loading a new doc before the previous one finished loading.
console.log("DocPageModel _openDoc cancelled");
return;
}
// Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors,
// show a modal, and include a toast for the sake of the "Report error" link.
reportError(err);
confirmModal(
"Error opening document",
"Reload",
async () => window.location.reload(true),
err.message,
{hideCancel: true},
);
}
private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode,
comparisonUrlId: string | undefined): Promise<void> {
console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +
(comparisonUrlId ? ` (compare ${comparisonUrlId})` : ''));
const gristDocModulePromise = loadGristDoc();
const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId));
const doc = buildDocInfo(docResponse, urlOpenMode);
flow.checkIfCancelled();
if (doc.urlId && doc.urlId !== urlId) {
// Replace the URL to reflect the canonical urlId.
await this.updateUrlNoReload(doc.urlId, doc.openMode, {replace: true});
}
this.currentDoc.set(doc);
// Maintain a connection to doc-worker while opening a document. After it's opened, the DocComm
// object created by GristDoc will maintain the connection.
const comm = this._appObj.comm;
comm.useDocConnection(doc.id);
flow.onDispose(() => comm.releaseDocConnection(doc.id));
const openDocResponse = await comm.openDoc(doc.id, doc.openMode);
const gdModule = await gristDocModulePromise;
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
flow.checkIfCancelled();
docComm.changeUrlIdEmitter.addListener(async (newUrlId: string) => {
// The current document has been forked, and should now be referred to using a new docId.
const currentDoc = this.currentDoc.get();
if (currentDoc) {
await this.updateUrlNoReload(newUrlId, 'default', {replace: false});
await this.updateCurrentDoc(newUrlId, 'default');
}
});
// If a document for comparison is given, load the comparison, and provide it to the Gristdoc.
const comparison = comparisonUrlId ?
await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse,
{comparison});
// Move ownership of docComm to GristDoc.
gristDoc.autoDispose(flow.release(docComm));
// Move ownership of GristDoc to its final owner.
this.gristDoc.autoDispose(flow.release(gristDoc));
}
private _getDocKey(state: IGristUrlState) {
const urlId = state.doc;
const urlOpenMode = state.mode || 'default';
const compareUrlId = state.params?.compare;
const docKey = `${urlOpenMode}:${urlId}:${compareUrlId}`;
return docKey;
}
}
function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly: boolean): DomElementArg[] {
const selectBy = gristDoc.selectBy.bind(gristDoc);
return [
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addNewPage(val),
{isNewPage: true, buttonLabel: 'Add Page'}),
menuIcon("Page"), "Add Page", testId('dp-add-new-page'),
dom.cls('disabled', isReadonly)
),
menuItem(
(elem) => openPageWidgetPicker(elem, gristDoc.docModel, (val) => gristDoc.addWidgetToPage(val),
{isNewPage: false, selectBy}),
menuIcon("Widget"), "Add Widget to Page", testId('dp-add-widget-to-page'),
dom.cls('disabled', isReadonly)
),
menuItem(() => gristDoc.addEmptyTable(), menuIcon("TypeTable"), "Add Empty Table", testId('dp-empty-table'),
dom.cls('disabled', isReadonly)),
menuDivider(),
...importSources.map((importSource, i) =>
menuItem(importSource.action,
menuIcon('Import'),
importSource.label,
testId(`dp-import-option`),
dom.cls('disabled', isReadonly)
)
),
isReadonly ? menuText('You do not have edit access to this document') : null,
testId('dp-add-new-menu')
];
}
function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo {
const idParts = parseUrlId(doc.urlId || doc.id);
const isFork = Boolean(idParts.forkId || idParts.snapshotId);
const isSample = !isFork && Boolean(doc.workspace.isSupportWorkspace);
const openMode = isSample ? 'fork' : mode;
const isPreFork = (openMode === 'fork');
const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;
const isEditable = canEdit(doc.access) || isPreFork;
return {
...doc,
isFork,
isSample,
isPreFork,
isBareFork,
isReadonly: !isEditable,
idParts,
openMode,
};
}
const reconnectIntervals = [1000, 1000, 2000, 5000, 10000];
async function retryOnNetworkError<R>(flow: AsyncFlow, func: () => Promise<R>): Promise<R> {
for (let attempt = 0; ; attempt++) {
try {
return await func();
} catch (err) {
// fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too.
if (err.name !== "TypeError" && err.name !== "NetworkError") {
throw err;
}
const reconnectTimeout = getReconnectTimeout(attempt, reconnectIntervals);
console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err);
await delay(reconnectTimeout);
flow.checkIfCancelled();
}
}
}

View File

@@ -0,0 +1,333 @@
import {guessTimezone} from 'app/client/lib/guessTimezone';
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {UserError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {ownerName} from 'app/client/models/WorkspaceInfo';
import {IHomePage} from 'app/common/gristUrls';
import {isLongerThan} from 'app/common/gutil';
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {Document, Workspace} from 'app/common/UserAPI';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
import flatten = require('lodash/flatten');
import sortBy = require('lodash/sortBy');
import * as moment from 'moment';
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";
app: AppModel;
currentPage: Observable<IHomePage>;
currentWSId: Observable<number|undefined>; // should be set when currentPage is 'workspace'
// Note that Workspace contains its documents in .docs.
workspaces: Observable<Workspace[]>;
loading: Observable<boolean|"slow">; // Set to "slow" when loading for a while.
available: Observable<boolean>; // set if workspaces loaded correctly.
showIntro: Observable<boolean>; // set if no docs and we should show intro.
singleWorkspace: Observable<boolean>; // set if workspace name should be hidden.
trashWorkspaces: Observable<Workspace[]>; // only set when viewing trash
// currentWS is undefined when currentPage is not "workspace" or if currentWSId doesn't exist.
currentWS: Observable<Workspace|undefined>;
// List of pinned docs to show for currentWS.
currentWSPinnedDocs: Observable<Document[]>;
currentSort: Observable<SortPref>;
currentView: Observable<ViewPref>;
// The workspace for new docs, or "unsaved" to only allow unsaved-doc creation, or null if the
// user isn't allowed to create a doc.
newDocWorkspace: Observable<Workspace|null|"unsaved">;
createWorkspace(name: string): Promise<void>;
renameWorkspace(id: number, name: string): Promise<void>;
deleteWorkspace(id: number, forever: boolean): Promise<void>;
restoreWorkspace(ws: Workspace): Promise<void>;
createDoc(name: string, workspaceId: number|"unsaved"): Promise<string>;
renameDoc(docId: string, name: string): Promise<void>;
deleteDoc(docId: string, forever: boolean): Promise<void>;
restoreDoc(doc: Document): Promise<void>;
pinUnpinDoc(docId: string, pin: boolean): Promise<void>;
moveDoc(docId: string, workspaceId: number): Promise<void>;
}
export interface ViewSettings {
currentSort: Observable<SortPref>;
currentView: Observable<ViewPref>;
}
export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings {
public readonly pageType = "home";
public readonly currentPage = Computed.create(this, urlState().state, (use, s) =>
s.homePage || (s.ws !== undefined ? "workspace" : "all"));
public readonly currentWSId = Computed.create(this, urlState().state, (use, s) => s.ws);
public readonly workspaces = Observable.create<Workspace[]>(this, []);
public readonly loading = Observable.create<boolean|"slow">(this, true);
public readonly available = Observable.create(this, false);
public readonly singleWorkspace = Observable.create(this, true);
public readonly trashWorkspaces = Observable.create<Workspace[]>(this, []);
// Get the workspace details for the workspace with id of currentWSId.
public readonly currentWS = Computed.create(this, (use) =>
use(this.workspaces).find(ws => (ws.id === use(this.currentWSId))));
public readonly currentWSPinnedDocs = Computed.create(this, this.currentPage, this.currentWS, (use, page, ws) => {
const docs = (page === 'all') ?
flatten((use(this.workspaces).map(w => w.docs))) :
(ws ? ws.docs : []);
return sortBy(docs.filter(doc => doc.isPinned), (doc) => doc.name.toLowerCase());
});
public readonly currentSort: Observable<SortPref>;
public readonly currentView: Observable<ViewPref>;
// The workspace for new docs, or "unsaved" to only allow unsaved-doc creation, or null if the
// user isn't allowed to create a doc.
public readonly newDocWorkspace = Computed.create(this, this.currentPage, this.currentWS, (use, page, ws) => {
// Anonymous user can create docs, but in unsaved mode.
if (!this.app.currentValidUser) { return "unsaved"; }
if (page === 'trash') { return null; }
const destWS = (page === 'all') ? (use(this.workspaces)[0] || null) : ws;
return destWS && roles.canEdit(destWS.access) ? destWS : null;
});
// Whether to show intro: no docs (other than examples) and user may create docs.
public readonly showIntro = Computed.create(this, this.workspaces, (use, wss) => (
wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0) &&
Boolean(use(this.newDocWorkspace))));
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
constructor(private _app: AppModel) {
super();
if (!this.app.currentValidUser) {
// For the anonymous user, use local settings, don't attempt to save anything to the server.
const viewSettings = makeLocalViewSettings(null, 'all');
this.currentSort = viewSettings.currentSort;
this.currentView = viewSettings.currentView;
} else {
// Preference for sorting. Defaults to 'name'. Saved to server on write.
this.currentSort = Computed.create(this, this._userOrgPrefs,
(use, prefs) => SortPref.parse(prefs?.docMenuSort) || 'name')
.onWrite(s => this._saveUserOrgPref("docMenuSort", s));
// Preference for view mode. The default is somewhat complicated. Saved to server on write.
this.currentView = Computed.create(this, this._userOrgPrefs,
(use, prefs) => ViewPref.parse(prefs?.docMenuView) || getViewPrefDefault(use(this.workspaces)))
.onWrite(s => this._saveUserOrgPref("docMenuView", s));
}
this.autoDispose(subscribe(this.currentPage, this.currentWSId, (use) =>
this._updateWorkspaces().catch(reportError)));
}
// Accessor for the AppModel containing this HomeModel.
public get app(): AppModel { return this._app; }
public async createWorkspace(name: string) {
const org = this._app.currentOrg;
if (!org) { return; }
this._checkForDuplicates(name);
await this._app.api.newWorkspace({name}, org.id);
await this._updateWorkspaces();
}
public async renameWorkspace(id: number, name: string) {
this._checkForDuplicates(name);
await this._app.api.renameWorkspace(id, name);
await this._updateWorkspaces();
}
public async deleteWorkspace(id: number, forever: boolean) {
// TODO: Prevent the last workspace from being removed.
await (forever ? this._app.api.deleteWorkspace(id) : this._app.api.softDeleteWorkspace(id));
await this._updateWorkspaces();
}
public async restoreWorkspace(ws: Workspace) {
await this._app.api.undeleteWorkspace(ws.id);
await this._updateWorkspaces();
reportError(new UserError(`Workspace "${ws.name}" restored`));
}
// Creates a new doc by calling the API, and returns its docId.
public async createDoc(name: string, workspaceId: number|"unsaved"): Promise<string> {
if (workspaceId === "unsaved") {
const timezone = await guessTimezone();
return await this._app.api.newUnsavedDoc({timezone});
}
const id = await this._app.api.newDoc({name}, workspaceId);
await this._updateWorkspaces();
return id;
}
public async renameDoc(docId: string, name: string): Promise<void> {
await this._app.api.renameDoc(docId, name);
await this._updateWorkspaces();
}
public async deleteDoc(docId: string, forever: boolean): Promise<void> {
await (forever ? this._app.api.deleteDoc(docId) : this._app.api.softDeleteDoc(docId));
await this._updateWorkspaces();
}
public async restoreDoc(doc: Document): Promise<void> {
await this._app.api.undeleteDoc(doc.id);
await this._updateWorkspaces();
reportError(new UserError(`Document "${doc.name}" restored`));
}
public async pinUnpinDoc(docId: string, pin: boolean): Promise<void> {
await (pin ? this._app.api.pinDoc(docId) : this._app.api.unpinDoc(docId));
await this._updateWorkspaces();
}
public async moveDoc(docId: string, workspaceId: number): Promise<void> {
await this._app.api.moveDoc(docId, workspaceId);
await this._updateWorkspaces();
}
private _checkForDuplicates(name: string): void {
if (this.workspaces.get().find(ws => ws.name === name)) {
throw new UserError('Name already exists. Please choose a different name.');
}
}
// Fetches and updates workspaces, which include contained docs as well.
private async _updateWorkspaces() {
const org = this._app.currentOrg;
if (org) {
this.loading.set(true);
const promises = Promise.all([
this._fetchWorkspaces(org.id, false).catch(reportError),
(this.currentPage.get() === 'trash') ? this._fetchWorkspaces(org.id, true).catch(reportError) : null,
]);
if (await isLongerThan(promises, DELAY_BEFORE_SPINNER_MS)) {
this.loading.set("slow");
}
const [wss, trashWss] = await promises;
// bundleChanges defers computeds' evaluations until all changes have been applied.
bundleChanges(() => {
this.workspaces.set(wss || []);
this.trashWorkspaces.set(trashWss || []);
this.loading.set(false);
this.available.set(!!wss);
// Hide workspace name if we are showing a single workspace, and active product
// doesn't allow adding workspaces. It is important to check both conditions because
// * A personal org, where workspaces can't be added, can still have multiple
// workspaces via documents shared by other users.
// * An org with workspace support might happen to just have one workspace right
// now, but it is good to show names to highlight the possibility of adding more.
this.singleWorkspace.set(!!wss && wss.length === 1 && _isSingleWorkspaceMode(this._app));
});
} else {
this.workspaces.set([]);
this.trashWorkspaces.set([]);
}
}
private async _fetchWorkspaces(orgId: number, forRemoved: boolean) {
let wss: Workspace[] = [];
try {
if (forRemoved) {
wss = await this._app.api.forRemoved().getOrgWorkspaces(orgId);
} else {
wss = await this._app.api.getOrgWorkspaces(orgId);
}
} catch (e) {
return null;
}
if (this.isDisposed()) { return null; }
for (const ws of wss) {
ws.docs = sortBy(ws.docs, (doc) => doc.name.toLowerCase());
// Populate doc.removedAt for soft-deleted docs even when deleted along with a workspace.
if (forRemoved) {
for (const doc of ws.docs) {
doc.removedAt = doc.removedAt || ws.removedAt;
}
}
}
// Sort workspaces such that workspaces from the personal orgs of others
// come after workspaces from our own personal org; workspaces from personal
// orgs are grouped by personal org and the groups are ordered alphabetically
// by owner name; and all else being equal workspaces are ordered alphabetically
// by their name. All alphabetical ordering is case-insensitive.
// Workspaces shared from support account (e.g. samples) are put last.
return sortBy(wss, (ws) => [ws.isSupportWorkspace,
ownerName(this._app, ws).toLowerCase(),
ws.name.toLowerCase()]);
}
private async _saveUserOrgPref<K extends keyof UserOrgPrefs>(key: K, value: UserOrgPrefs[K]) {
const org = this._app.currentOrg;
if (org) {
org.userOrgPrefs = {...org.userOrgPrefs, [key]: value};
this._userOrgPrefs.set(org.userOrgPrefs);
await this._app.api.updateOrg('current', {userOrgPrefs: org.userOrgPrefs});
}
}
}
// Check if active product allows just a single workspace.
function _isSingleWorkspaceMode(app: AppModel): boolean {
return app.currentFeatures.maxWorkspacesPerOrg === 1;
}
// Returns a default view mode preference. We used to show 'list' for everyone. We now default to
// 'icons' for new or light users. But if a user has more than 4 docs or any pinned docs, we'll
// switch to 'list'. This will also avoid annoying existing users who may prefer a list.
function getViewPrefDefault(workspaces: Workspace[]): ViewPref {
const userWorkspaces = workspaces.filter(ws => !ws.isSupportWorkspace);
const numDocs = userWorkspaces.reduce((sum, ws) => sum + ws.docs.length, 0);
const pinnedDocs = userWorkspaces.some((ws) => ws.docs.some(doc => doc.isPinned));
return (numDocs > 4 || pinnedDocs) ? 'list' : 'icons';
}
/**
* Create observables for per-workspace view settings which default to org-wide settings, but can
* be changed independently and persisted in localStorage.
*/
export function makeLocalViewSettings(home: HomeModel|null, wsId: number|'trash'|'all'): ViewSettings {
const userId = home?.app.currentUser?.id || 0;
const sort = localStorageObs(`u=${userId}:ws=${wsId}:sort`);
const view = localStorageObs(`u=${userId}:ws=${wsId}:view`);
return {
currentSort: Computed.create(null,
// If no value in localStorage, use sort of All Documents.
(use) => SortPref.parse(use(sort)) || (home ? use(home.currentSort) : 'name'))
.onWrite((val) => sort.set(val)),
currentView: Computed.create(null,
// If no value in localStorage, use mode of All Documents, except Trash which defaults to 'list'.
(use) => ViewPref.parse(use(view)) || (wsId === 'trash' ? 'list' : (home ? use(home.currentView) : 'icons')))
.onWrite((val) => view.set(val)),
};
}

View File

@@ -0,0 +1,83 @@
var _ = require('underscore');
var ko = require('knockout');
var dispose = require('../lib/dispose');
var BaseRowModel = require('./BaseRowModel');
var modelUtil = require('./modelUtil');
var BackboneEvents = require('backbone').Events;
/**
* MetaRowModel is a RowModel for built-in (Meta) tables. It takes a list of field names, and an
* additional constructor called with (docModel, tableModel) arguments (and `this` context), which
* can add arbitrary additional properties to this RowModel.
*/
function MetaRowModel(tableModel, fieldNames, rowConstructor, rowId) {
var colNames = ['id'].concat(fieldNames);
BaseRowModel.call(this, tableModel, colNames);
this._rowId = rowId;
// MetaTableModel#_createRowModelItem creates lightweight objects that all reference the same MetaRowModel but are slightly different.
// We don't derive from BackboneEvents directly so that the lightweight objects created share the same Events object even though they are distinct.
this.events = this.autoDisposeWith('stopListening', BackboneEvents);
// Changes to true when this row gets deleted. This also likely means that this model is about
// to get disposed, except for a floating row model.
this._isDeleted = ko.observable(false);
// Populate all fields. Note that MetaRowModels are never get reassigned after construction.
this._fields.forEach(function(colName) {
this._assignColumn(colName);
}, this);
// Customize the MetaRowModel with a custom additional constructor.
if (rowConstructor) {
rowConstructor.call(this, tableModel.docModel, tableModel);
}
}
dispose.makeDisposable(MetaRowModel);
_.extend(MetaRowModel.prototype, BaseRowModel.prototype);
MetaRowModel.prototype._assignColumn = function(colName) {
if (this.hasOwnProperty(colName)) {
this[colName].assign(this._table.tableData.getValue(this._rowId, colName));
}
};
//----------------------------------------------------------------------
/**
* MetaRowModel.Floater is an object designed to look like a MetaRowModel. It contains observables
* that mirror some particular MetaRowModel. The MetaRowModel currently being mirrored is the one
* corresponding to the value of `rowIdObs`.
*
* Mirrored fields are computed observables that support reading, writing, and saving.
*/
MetaRowModel.Floater = function(tableModel, rowIdObs) {
this._table = tableModel;
this.rowIdObs = rowIdObs;
// Note that ._index isn't supported because it doesn't make sense for a floating row model.
this._underlyingRowModel = this.autoDispose(ko.computed(function() {
return tableModel.getRowModel(rowIdObs());
}));
_.each(this._underlyingRowModel(), function(propValue, propName) {
if (ko.isObservable(propValue)) {
// Forward read/write calls to the observable on the currently-active underlying model.
this[propName] = this.autoDispose(ko.pureComputed({
owner: this,
read: function() { return this._underlyingRowModel()[propName](); },
write: function(val) { this._underlyingRowModel()[propName](val); }
}));
// If the underlying observable supports saving, forward save calls too.
if (propValue.saveOnly) {
modelUtil.addSaveInterface(this[propName], (value =>
this._underlyingRowModel()[propName].saveOnly(value)));
}
}
}, this);
};
dispose.makeDisposable(MetaRowModel.Floater);
module.exports = MetaRowModel;

View File

@@ -0,0 +1,241 @@
/**
* MetaTableModel maintains the model for a built-in table, with MetaRowModels. It provides
* access to individual row models, as well as to collections of rows in that table.
*/
var _ = require('underscore');
var ko = require('knockout');
var dispose = require('../lib/dispose');
var MetaRowModel = require('./MetaRowModel');
var TableModel = require('./TableModel');
var rowset = require('./rowset');
var assert = require('assert');
var gutil = require('app/common/gutil');
/**
* MetaTableModel maintains observables for one table's rows. It accepts a list of fields to
* include into each RowModel, and an additional constructor to call when constructing RowModels.
* It exposes all rows, as well as groups of rows, as observable collections.
*/
function MetaTableModel(docModel, tableData, fields, rowConstructor) {
TableModel.call(this, docModel, tableData);
this._fields = fields;
this._rowConstructor = rowConstructor;
// Start out with empty list of row models. It's populated in loadData().
this.rowModels = [];
// It is possible for a new rowModel to be deleted and replaced with a new one for the same
// rowId. To allow a computed() to depend on the row version, we keep a permanent observable
// "version" associated with each rowId, which is incremented any time a rowId is replaced.
this._rowModelVersions = [];
// Whenever rowNotify is triggered, also send the action to all row RowModels that we maintain.
this.listenTo(this, 'rowNotify', function(rows, action) {
assert(rows !== rowset.ALL, "Unexpected schema action on a metadata table");
for (let r of rows) {
if (this.rowModels[r]) {
this.rowModels[r].dispatchAction(action);
}
}
});
}
dispose.makeDisposable(MetaTableModel);
_.extend(MetaTableModel.prototype, TableModel.prototype);
/**
* This is called from DocModel as soon as all the MetaTableModel objects have been created.
*/
MetaTableModel.prototype.loadData = function() {
// Whereas user-defined tables may not be initially loaded, MetaTableModels should only exist
// for built-in tables, which *should* already be loaded (and should never be reloaded).
assert(this.tableData.isLoaded, "MetaTableModel: tableData not yet loaded");
// Create and populate the array mapping rowIds to RowModels.
this.getAllRows().forEach(function(rowId) {
this._createRowModel(rowId);
}, this);
};
/**
* Returns an existing or a blank row. Used for `recordRef` descriptor in DocModel.
*
* A computed() that uses getRowModel() may not realize if a rowId gets deleted and later re-used
* for another row. If optDependOnVersion is set, then a dependency on the row version gets
* created automatically. It is only relevant when the computed is pure and may not get updated
* when the row is deleted; in that case lacking such dependency may cause subtle rare bugs.
*/
MetaTableModel.prototype.getRowModel = function(rowId, optDependOnVersion) {
let r = this.rowModels[rowId] || this.getEmptyRowModel();
if (optDependOnVersion) {
this._rowModelVersions[rowId]();
}
return r;
};
/**
* Returns the RowModel to use for invalid rows.
*/
MetaTableModel.prototype.getEmptyRowModel = function() {
return this._createRowModel(0);
};
/**
* Private helper to create a MetaRowModel for the given rowId. For public use, there are
* getRowModel(rowId) and createFloatingRowModel(rowIdObs).
*/
MetaTableModel.prototype._createRowModel = function(rowId) {
if (!this.rowModels[rowId]) {
// When creating a new row, we create new MetaRowModels which use observables. If
// _createRowModel is called from within the evaluation of a computed(), we do NOT want that
// computed to subscribe to observables used by individual MetaRowModels.
ko.ignoreDependencies(() => {
this.rowModels[rowId] = MetaRowModel.create(this, this._fields, this._rowConstructor, rowId);
// Whenever a rowModel is created, increment its version number.
let inc = this._rowModelVersions[rowId] || (this._rowModelVersions[rowId] = ko.observable(0));
inc(inc.peek() + 1);
});
}
return this.rowModels[rowId];
};
/**
* Returns a MetaRowModel-like object tied to an observable rowId. When the observable changes,
* the fields of the returned model start reflecting the values for the new rowId. See also
* MetaRowModel.Floater docs.
*
* There should be very few such floating rows. If you ever want a set, you should be using
* createAllRowsModel() or createRowGroupModel().
*
* @param {ko.observable} rowIdObs: observable that evaluates to a rowId.
*/
MetaTableModel.prototype.createFloatingRowModel = function(rowIdObs) {
return MetaRowModel.Floater.create(this, rowIdObs);
};
/**
* Override TableModel's _process_RemoveRecord to also remove our reference to this row model.
*/
MetaTableModel.prototype._process_RemoveRecord = function(action, tableId, rowId) {
TableModel.prototype._process_RemoveRecord.apply(this, arguments);
this._deleteRowModel(rowId);
};
/**
* Clean up the RowModel for a row when it's deleted by an action from the server.
*/
MetaTableModel.prototype._deleteRowModel = function(rowId) {
this.rowModels[rowId]._isDeleted(true);
this.rowModels[rowId].dispose();
delete this.rowModels[rowId];
};
/**
* We have to remember to override Bulk versions too.
*/
MetaTableModel.prototype._process_BulkRemoveRecord = function(action, tableId, rowIds) {
TableModel.prototype._process_BulkRemoveRecord.apply(this, arguments);
rowIds.forEach(rowId => this._deleteRowModel(rowId));
};
/**
* Override TableModel's _process_AddRecord to also add a row model for the given rowId.
*/
MetaTableModel.prototype._process_AddRecord = function(action, tableId, rowId, columnValues) {
this._createRowModel(rowId);
TableModel.prototype._process_AddRecord.apply(this, arguments);
};
/**
* We have to remember to override Bulk versions too.
*/
MetaTableModel.prototype._process_BulkAddRecord = function(action, tableId, rowIds, columns) {
rowIds.forEach(rowId => this._createRowModel(rowId));
TableModel.prototype._process_BulkAddRecord.apply(this, arguments);
};
/**
* Override TableModel's applySchemaAction to assert that there are NO metadata schema changes.
*/
MetaTableModel.prototype.applySchemaAction = function(action) {
throw new Error("No schema actions should apply to metadata");
};
/**
* Returns a new observable array (koArray) of MetaRowModels for all the rows in this table,
* sorted by the given column. It is the caller's responsibility to dispose this array.
* @param {string} sortColId: Column ID by which to sort.
*/
MetaTableModel.prototype.createAllRowsModel = function(sortColId) {
return this._createRowSetModel(this, sortColId);
};
/**
* Returns a new observable array (koArray) of MetaRowModels matching the given `groupValue`.
* It is the caller's responsibility to dispose this array.
* @param {String|Number} groupValue - The group value to match.
* @param {String} options.groupBy - RowModel field by which to group.
* @param {String} options.sortBy - RowModel field by which to sort.
*/
MetaTableModel.prototype.createRowGroupModel = function(groupValue, options) {
var grouping = this.getRowGrouping(options.groupBy);
return this._createRowSetModel(grouping.getGroup(groupValue), options.sortBy);
};
/**
* Helper that returns a new observable koArray of MetaRowModels subscribed to the given
* rowSource, and sorted by the given column. It is the caller's responsibility to dispose it.
*/
MetaTableModel.prototype._createRowSetModel = function(rowSource, sortColId) {
var getter = this.tableData.getRowPropFunc(sortColId);
var sortedRowSet = rowset.SortedRowSet.create(null, function(r1, r2) {
return gutil.nativeCompare(getter(r1), getter(r2));
});
sortedRowSet.subscribeTo(rowSource);
// When the returned value is disposed, dispose the underlying SortedRowSet too.
var ret = this._createRowModelArray(sortedRowSet.getKoArray());
ret.autoDispose(sortedRowSet);
return ret;
};
/**
* Helper which takes an observable array (koArray) of rowIds, and returns a new koArray of
* objects having those RowModels as prototypes, and with an additional `_index` observable to
* contain their index in the array. The index is kept correct as the array changes.
*
* TODO: this needs a unittest.
*/
MetaTableModel.prototype._createRowModelArray = function(rowIdArray) {
var ret = rowIdArray.map(this._createRowModelItem, this);
ret.subscribe(function(splice) {
var arr = splice.array, i;
for (i = 0; i < splice.deleted.length; i++) {
splice.deleted[i]._index(null);
}
var delta = splice.added - splice.deleted.length;
if (delta !== 0) {
for (i = splice.start + splice.added; i < arr.length; i++) {
arr[i]._index(i);
}
}
}, null, 'spliceChange');
return ret;
};
/**
* Creates and returns a RowModel with its own `_index` observable.
*/
MetaTableModel.prototype._createRowModelItem = function(rowId, index) {
var rowModel = this._createRowModel(rowId);
assert.ok(rowModel, "MetaTableModel._createRowModelItem called for invalid rowId " + rowId);
var ret = Object.create(rowModel); // New object, with rowModel as its prototype.
ret._index = ko.observable(index); // New _index observable overrides the existing one.
return ret;
};
module.exports = MetaTableModel;

View File

@@ -0,0 +1,369 @@
import * as log from 'app/client/lib/log';
import {ConnectState, ConnectStateManager} from 'app/client/models/ConnectState';
import {delay} from 'app/common/delay';
import {isLongerThan} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer';
import {timeFormat} from 'app/common/timeFormat';
import {bundleChanges, Disposable, Holder, IDisposable, IDisposableOwner } from 'grainjs';
import {Computed, dom, DomElementArg, MutableObsArray, obsArray, Observable} from 'grainjs';
import clamp = require('lodash/clamp');
// When rendering app errors, we'll only show the last few.
const maxAppErrors = 5;
interface INotifier {
// If you are looking to report errors, please do that via reportError rather
// than these methods so that we have a chance to send the error to our logs.
createUserError(message: string, options?: INotifyOptions): INotification;
createAppError(error: Error): void;
createProgressIndicator(name: string, size: string, expireOnComplete: boolean): IProgress;
createNotification(options: INotifyOptions): INotification;
setConnectState(isConnected: boolean): void;
slowNotification<T>(promise: Promise<T>, optTimeout?: number): Promise<T>;
getFullAppErrors(): IAppError[];
}
interface INotification extends Expirable {
expire(): Promise<void>;
}
export interface IProgress extends Expirable {
setProgress(percent: number): void;
}
// Identifies supported actions. These are implemented in NotifyUI.
export type NotifyAction = 'upgrade' | 'renew' | 'report-problem' | 'ask-for-help';
export interface INotifyOptions {
message: string | (() => DomElementArg); // A string, or a function that builds dom.
timestamp?: number;
title?: string;
canUserClose?: boolean;
inToast?: boolean;
inDropdown?: boolean;
expireSec?: number;
badgeCounter?: boolean;
// cssToastAction class from NotifyUI will be applied automatically to action elements.
actions?: NotifyAction[];
// When set, the notification will replace any previous notification with the same key.
// This way, we can avoid accumulating many of substantially identical notifications.
key?: string|null;
}
type Status = 'active' | 'expiring';
export class Expirable extends Disposable {
public static readonly fadeDelay = 250;
public readonly status = Observable.create<Status>(this, 'active');
constructor() {
super();
}
/**
* Sets status to 'expiring', then calls dispose after a short delay.
*/
public async expire(): Promise<void> {
this.status.set('expiring');
await delay(Expirable.fadeDelay);
if (!this.isDisposed()) {
this.dispose();
}
}
}
export class Notification extends Expirable implements INotification {
public options: Required<INotifyOptions> = {
title: '',
message: '',
timestamp: Date.now(),
inDropdown: false,
badgeCounter: false,
inToast: true,
expireSec: 0,
canUserClose: false,
actions: [],
key: null,
};
constructor(_opts: INotifyOptions) {
super();
Object.assign(this.options, _opts);
if (this.options.expireSec > 0) {
const expireTimer = setTimeout(() => this.expire(), 1000 * this.options.expireSec);
this.onDispose(() => clearTimeout(expireTimer));
}
}
}
interface IProgressOptions {
name: string;
size: string;
expireOnComplete?: boolean;
}
export class Progress extends Expirable implements IProgress {
public readonly progress = Observable.create(this, 0);
constructor(public options: IProgressOptions) {
super();
if (options.expireOnComplete) {
this.autoDispose(this.progress.addListener(async progress => {
if (progress >= 100) {
await this.expire();
}
}));
}
}
/**
* progress should be between 0 and 100.
*/
public setProgress(progress: number) {
this.progress.set(clamp(progress, 0, 100));
}
}
/**
* Similar to grainjs MultiHolder, but knows when items are disposed externally and releases them
* (avoiding the "already disposed" warnings in that case). This is probably how grainjs's
* MultiHolder should actually work, and maybe how `Disposable.autoDispose` should generally work.
*/
export class BetterMultiHolder implements IDisposableOwner {
private _items = new Set<IDisposable>();
public autoDispose<T extends IDisposable>(obj: T): T {
this._items.add(obj);
if (obj instanceof Disposable) {
obj.onDispose(() => this._items.delete(obj));
}
return obj;
}
public dispose() {
for (const item of this._items) {
item.dispose();
}
this._items.clear();
}
}
export interface IAppError {
error: Error;
timestamp: number;
seen?: boolean; // If seen, this will be hidden from the "app errors" toast
}
export class Notifier extends Disposable implements INotifier {
private _itemsHolder = this.autoDispose(new BetterMultiHolder());
private _toasts = this.autoDispose(obsArray<Notification>());
private _dropdownItems = this.autoDispose(obsArray<Notification>());
private _progressItems = this.autoDispose(obsArray<Progress>([]));
private _keyedItems = new Map<string, Notification>();
private _connectStateManager = ConnectStateManager.create(this);
private _connectState = this._connectStateManager.connectState;
private _disconnectMsg = Computed.create(this, (use) => getDisconnectMessage(use(this._connectState)));
// Holds recent application errors, which the user may report to us.
private _appErrorList = this.autoDispose(obsArray<IAppError>());
// The dropdown will show all recent errors; the toast only the "new" ones, i.e. those since the
// last toast was closed.
private _appErrorDropdownItem = Holder.create<INotification>(this);
private _appErrorToast = Holder.create<INotification>(this);
private _slowNotificationToast = Holder.create<INotification>(this);
private _slowNotificationInactivityTimer = new InactivityTimer(() => this._slowNotificationToast.clear(), 0);
constructor() {
super();
Computed.create(this, this._disconnectMsg, (use, msg) =>
msg ? use.owner.autoDispose(this.createNotification({
message: msg.message,
title: msg.title,
canUserClose: true,
inToast: true,
})) : null);
}
/**
* Exposes all the state needed for building UI. This is simply to clarify the intended usage:
* these members aren't intended to be exposed, except to the UI-building code.
*/
public getStateForUI() {
return {
toasts: this._toasts,
dropdownItems: this._dropdownItems,
progressItems: this._progressItems,
connectState: this._connectState,
disconnectMsg: this._disconnectMsg,
};
}
/**
* Creates a basic toast user error. By default, expires in 5 seconds.
* Takes an options objects to configure `expireSec` and `canUserClose`.
* Set `expireSec` to 0 to prevent expiration.
*
* If you are looking to report errors, please do that via reportError so
* that we have a chance to send the error to our logs.
*/
public createUserError(message: string, options: Partial<INotifyOptions> = {}): INotification {
const timestamp = Date.now();
if (options.actions && options.actions.includes('ask-for-help')) {
// If user should be able to ask for help, add this error to the notifier dropdown too for a
// good while, so the user can find it after the toast disappears.
this.createNotification({
timestamp,
message,
inToast: false,
expireSec: 300,
canUserClose: true,
inDropdown: true,
...options,
key: options.key && ("dropdown:" + options.key),
});
}
return this.createNotification({
timestamp,
message,
inToast: true,
expireSec: 10,
canUserClose: true,
inDropdown: false,
...options,
});
}
/**
* If you are looking to report errors, please do that via reportError so
* that we have a chance to send the error to our logs.
*/
public createAppError(error: Error): void {
bundleChanges(() => {
// Remove old messages, to keep a max of maxAppErrors.
if (this._appErrorList.get().length >= maxAppErrors) {
this._appErrorList.splice(0, this._appErrorList.get().length - maxAppErrors + 1);
}
this._appErrorList.push({error, timestamp: Date.now()});
});
// Create a dropdown item for errors if we don't have one yet.
if (this._appErrorDropdownItem.isEmpty()) {
this._appErrorDropdownItem.autoDispose(this._createAppErrorItem('dropdown'));
}
// Create a toast for errors if we don't have one yet. When it's closed, mark the items as
// "seen" (i.e. not to be shown when the toast pops up again).
if (this._appErrorToast.isEmpty()) {
const n = this._appErrorToast.autoDispose(this._createAppErrorItem('toast'));
n.onDispose(() => this._appErrorList.get().forEach((appErr) => { appErr.seen = true; }));
}
}
public createNotification(opts: INotifyOptions): INotification {
const n = Notification.create(this._itemsHolder, opts);
this._addNotification(n).catch((e) => { log.warn('_addNotification failed', e); });
return n;
}
public createProgressIndicator(name: string, size: string, expireOnComplete = false): IProgress {
// Progress objects normally dispose themselves; constructor disposes any leftover items.
const p = Progress.create(this._itemsHolder, {name, size, expireOnComplete});
this._progressItems.push(p);
p.onDispose(() => this.isDisposed() || arrayRemove(this._progressItems, p));
return p;
}
public setConnectState(isConnected: boolean): void {
this._connectStateManager.setConnected(isConnected);
}
public getFullAppErrors() {
return this._appErrorList.get();
}
// This is exposed primarily for tests.
public clearAppErrors() {
this._appErrorList.splice(0);
}
/**
* Show a notification when promise takes longer than optTimeout to resolve. Returns the passed in
* promise.
*/
public async slowNotification<T>(promise: Promise<T>, optTimeout: number = 1000): Promise<T> {
if (await isLongerThan(promise, optTimeout)) {
if (this._slowNotificationToast.isEmpty()) {
this._slowNotificationToast.autoDispose(this.createNotification({
message: "Still working...",
canUserClose: false,
inToast: true,
}));
}
await this._slowNotificationInactivityTimer.disableUntilFinish(promise);
}
return promise;
}
private async _addNotification(n: Notification): Promise<void> {
const key = n.options.key;
if (key) {
const prev = this._keyedItems.get(key);
if (prev) {
await prev.expire();
}
this._keyedItems.set(key, n);
n.onDispose(() => this.isDisposed() || this._keyedItems.delete(key));
}
if (n.options.inToast) {
this._toasts.push(n);
n.onDispose(() => this.isDisposed() || arrayRemove(this._toasts, n));
}
if (n.options.inDropdown) {
this._dropdownItems.push(n);
n.onDispose(() => this.isDisposed() || arrayRemove(this._dropdownItems, n));
}
}
private _createAppErrorItem(where: 'toast' | 'dropdown') {
return this.createNotification({
// Building DOM here in NotifyModel seems wrong, but I haven't come up with a better way.
message: () => dom.forEach(this._appErrorList, (appErr: IAppError) =>
(where === 'toast' && appErr.seen ? null :
dom('div', timeFormat('T', new Date(appErr.timestamp)), ' ', appErr.error.message)
)
),
title: 'Unexpected error',
canUserClose: true,
inToast: where === 'toast',
expireSec: where === 'toast' ? 10 : 0,
inDropdown: where === 'dropdown',
actions: ['report-problem'],
});
}
}
function arrayRemove<T>(arr: MutableObsArray<T>, elem: T) {
const removeIdx = arr.get().findIndex(e => e === elem);
if (removeIdx !== -1) {
arr.splice(removeIdx, 1);
}
}
function getDisconnectMessage(state: ConnectState): {title: string, message: string}|undefined {
switch (state) {
case ConnectState.RecentlyDisconnected:
return {title: 'Connection is lost', message: 'Attempting to reconnect...'};
case ConnectState.ReallyDisconnected:
return {title: 'Not connected', message: 'The document is in read-only mode until you are back online.'};
}
}

View File

@@ -0,0 +1,359 @@
/**
* A QuerySet represents a data query to the server, which returns matching data and includes a
* subscription. The subscription tells the server to send us docActions that affect this query.
*
* This file combines several classes related to it:
*
* - QuerySetManager is maintained by GristDoc, and keeps all active QuerySets for this doc.
* A new one is created using QuerySetManager.useQuerySet(owner, query)
*
* This creates a subscription to the server, and sets up owner.autoDispose() to clean up
* that subscription. If a subscription already exists, it only returns a reference to it,
* and disposal will remove the reference, only unsubscribing from the server when no
* referernces remain.
*
* - DynamicQuerySet is used by BaseView (in place of FilteredRowSource used previously). It is a
* single RowSource which mirrors a QuerySet, and allows the QuerySet to be changed.
* You set it to a new query using DynamicQuerySet.makeQuery(...)
*
* - QuerySet represents the actual query, makes the calls to the server to populate the data in
* the relevant TableData. It is also a FilteredRowSource for the rows matching the query.
*
* - TableQuerySets is a simple set of queries maintained for a single table (by DataTableModel).
* It's needed to know which rows are still relevant after a QuerySet is disposed.
*
* TODO: need to have a fetch limit (e.g. 1000 by default, or an option for user)
* TODO: client-side should show "..." or "50000 more rows not shown" in that case.
* TODO: Reference columns don't work properly because always use a displayCol which relies on formulas
*/
import * as DataTableModel from 'app/client/models/DataTableModel';
import {DocModel} from 'app/client/models/DocModel';
import {BaseFilteredRowSource, FilterFunc, RowId, RowList, RowSource} from 'app/client/models/rowset';
import {TableData} from 'app/client/models/TableData';
import {ActiveDocAPI, Query} from 'app/common/ActiveDocAPI';
import {TableDataAction} from 'app/common/DocActions';
import {DocData} from 'app/common/DocData';
import {nativeCompare} from 'app/common/gutil';
import {IRefCountSub, RefCountMap} from 'app/common/RefCountMap';
import {TableData as BaseTableData} from 'app/common/TableData';
import {tbind} from 'app/common/tbind';
import {Disposable, Holder, IDisposableOwnerT} from 'grainjs';
import * as ko from 'knockout';
import debounce = require('lodash/debounce');
// Limit on the how many rows to request for OnDemand tables.
const ON_DEMAND_ROW_LIMIT = 10000;
// Copied from app/server/lib/DocStorage.js. Actually could be 999, we are just playing it safe.
const MAX_SQL_PARAMS = 500;
/**
* A representation of a Query that uses tableRef/colRefs (i.e. metadata rowIds) to remain stable
* across table/column renames.
*/
export interface QueryRefs {
tableRef: number;
filterPairs: Array<[number, any[]]>;
}
/**
* QuerySetManager keeps track of all queries for a GristDoc instance. It is also responsible for
* disposing all state associated with queries when a GristDoc is disposed.
*
* Note that queries are made using tableId + colIds, which is a more suitable interface for a
* (future) public API, and easier to interact with DocData/TableData. However, it creates
* problems when tables or columns are renamed or deleted.
*
* To handle renames, we keep track of queries using their QueryRef representation, using
* tableRef/colRefs, i.e. metadata rowIds that aren't affected by renames.
*
* To handle deletes, we subscribe to isDeleted() observables of the needed tables and columns,
* and purge the query from QuerySetManager if any isDeleted() flag becomes true.
*/
export class QuerySetManager extends Disposable {
private _queryMap: RefCountMap<string, QuerySet>;
constructor(private _docModel: DocModel, docComm: ActiveDocAPI) {
super();
this._queryMap = this.autoDispose(new RefCountMap<string, QuerySet>({
create: (query: string) => QuerySet.create(null, _docModel, docComm, query, this),
dispose: (query: string, querySet: QuerySet) => querySet.dispose(),
gracePeriodMs: 60000, // Dispose after a minute of disuse.
}));
}
public useQuerySet(owner: IDisposableOwnerT<IRefCountSub<QuerySet>>, query: Query): QuerySet {
// Convert the query to a string key which identifies it.
const queryKey: string = encodeQuery(convertQueryToRefs(this._docModel, query));
// Look up or create the query in the RefCountMap. The returned object is a RefCountSub
// subscription, which decrements reference count when disposed.
const querySetRefCount = this._queryMap.use(queryKey);
// The passed-in owner is what will dispose this subscription (decrement reference count).
owner.autoDispose(querySetRefCount);
return querySetRefCount.get();
}
public purgeKey(queryKey: string) {
this._queryMap.purgeKey(queryKey);
}
}
/**
* DynamicQuerySet wraps one QuerySet, and allows changing it on the fly. It serves as a
* RowSource.
*/
export class DynamicQuerySet extends RowSource {
// Holds a reference to the currently active QuerySet.
private _holder = Holder.create<IRefCountSub<QuerySet>>(this);
// Shortcut to _holder.get().get().
private _querySet?: QuerySet;
// We could switch between several different queries quickly. If several queries are done
// fetching at the same time (e.g. were already ready), debounce lets us only update the
// query-set once to the last query.
private _updateQuerySetDebounced = debounce(tbind(this._updateQuerySet, this), 0);
constructor(private _querySetManager: QuerySetManager, private _tableModel: DataTableModel) {
super();
}
public getAllRows(): RowList {
return this._querySet ? this._querySet.getAllRows() : [];
}
/**
* Tells whether the query's result got truncated, i.e. not all rows are included.
*/
public get isTruncated(): boolean {
return this._querySet ? this._querySet.isTruncated : false;
}
/**
* Replace the query represented by this DynamicQuerySet. If multiple makeQuery() calls are made
* quickly (while waiting for the server), cb() may only be called for the latest one.
*
* If there is an error fetching data, cb(err) will be called with that error. The second
* argument to cb() is true if any data was changed, and false if not. Note that for a series of
* makeQuery() calls, cb() is always called at least once, and always asynchronously.
*/
public makeQuery(filters: {[colId: string]: any[]}, cb: (err: Error|null, changed: boolean) => void): void {
const query: Query = {tableId: this._tableModel.tableData.tableId, filters};
const newQuerySet = this._querySetManager.useQuerySet(this._holder, query);
// CB should be called asynchronously, since surprising hard-to-debug interactions can happen
// if it's sometimes synchronous and sometimes not.
newQuerySet.fetchPromise.then(() => {
this._updateQuerySetDebounced(newQuerySet, cb);
})
.catch((err) => { cb(err, false); });
}
private _updateQuerySet(nextQuerySet: QuerySet, cb: (err: Error|null, changed: boolean) => void): void {
try {
if (nextQuerySet !== this._querySet) {
const oldQuerySet = this._querySet;
this._querySet = nextQuerySet;
if (oldQuerySet) {
this.stopListening(oldQuerySet, 'rowChange');
this.stopListening(oldQuerySet, 'rowNotify');
this.trigger('rowChange', 'remove', oldQuerySet.getAllRows());
}
this.trigger('rowChange', 'add', this._querySet.getAllRows());
this.listenTo(this._querySet, 'rowNotify', tbind(this.trigger, this, 'rowNotify'));
this.listenTo(this._querySet, 'rowChange', tbind(this.trigger, this, 'rowChange'));
}
cb(null, true);
} catch (err) {
cb(err, true);
}
}
}
/**
* Class representing a query, which knows how to fetch the data, an presents a RowSource with
* matching rows. It uses new Comm calls for onDemand tables, but for regular tables, fetching
* data uses the good old tableModel.fetch(). In in most cases the data is already available, so
* this class is little more than a FilteredRowSource.
*/
export class QuerySet extends BaseFilteredRowSource {
// A publicly exposed promise, which may be waited on in order to know that the data has
// arrived. Until then, the RowSource underlying this QuerySet is empty.
public readonly fetchPromise: Promise<void>;
// Whether the fetched result is considered incomplete, i.e. not all rows were fetched.
public isTruncated: boolean;
constructor(docModel: DocModel, docComm: ActiveDocAPI, queryKey: string, qsm: QuerySetManager) {
const queryRefs: QueryRefs = decodeQuery(queryKey);
const query: Query = convertQueryFromRefs(docModel, queryRefs);
super(getFilterFunc(docModel.docData, query));
this.isTruncated = false;
// When table or any needed columns are deleted, purge this QuerySet from the map.
const isInvalid = this.autoDispose(makeQueryInvalidComputed(docModel, queryRefs));
this.autoDispose(isInvalid.subscribe((invalid) => {
if (invalid) { qsm.purgeKey(queryKey); }
}));
// Find the relevant DataTableModel.
const tableModel = docModel.dataTables[query.tableId];
// The number of values across all filters is limited to MAX_SQL_PARAMS. Normally a query has
// a single filter column, but in case there are multiple we divide the limit across all
// columns. It's OK to modify the query in place, since this modified version is not used
// elsewhere.
// (It might be better to limit this in DocStorage.js, but by limiting here, it's easier to
// know when to set isTruncated flag, to inform the user that data is incomplete.)
const colIds = Object.keys(query.filters);
if (colIds.length > 0) {
const maxParams = Math.floor(MAX_SQL_PARAMS / colIds.length);
for (const c of colIds) {
const values = query.filters[c];
if (values.length > maxParams) {
query.filters[c] = values.slice(0, maxParams);
this.isTruncated = true;
}
}
}
let fetchPromise: Promise<void>;
if (tableModel.tableMetaRow.onDemand()) {
const tableQS = tableModel.tableQuerySets;
fetchPromise = docComm.useQuerySet({limit: ON_DEMAND_ROW_LIMIT, ...query}).then((data) => {
// We assume that if we fetched the max number of rows, that there are likely more and the
// result should be reported as truncated.
// TODO: Better to fetch ON_DEMAND_ROW_LIMIT + 1 and omit one of them, so that isTruncated
// is only set if the row limit really was exceeded.
const rowIds = data.tableData[2];
if (rowIds.length >= ON_DEMAND_ROW_LIMIT) {
this.isTruncated = true;
}
this.onDispose(() => {
docComm.disposeQuerySet(data.querySubId).catch((err) => {
// tslint:disable-next-line:no-console
console.log(`Promise rejected for disposeQuerySet: ${err.message}`);
});
tableQS.removeQuerySet(this);
});
tableQS.addQuerySet(this, data.tableData);
});
} else {
// For regular (small), we fetch in bulk (and do nothing if already fetched).
fetchPromise = tableModel.fetch(false);
}
// This is a FilteredRowSource; subscribe it to the underlying data once the fetch resolves.
this.fetchPromise = fetchPromise.then(() => this.subscribeTo(tableModel));
}
}
/**
* Helper for use in a DataTableModel to maintain all QuerySets.
*/
export class TableQuerySets {
private _querySets: Set<QuerySet> = new Set();
constructor(private _tableData: TableData) {}
public addQuerySet(querySet: QuerySet, data: TableDataAction): void {
this._querySets.add(querySet);
this._tableData.loadPartial(data);
}
// Returns a Set of unused RowIds from querySet.
public removeQuerySet(querySet: QuerySet): void {
this._querySets.delete(querySet);
// Figure out which rows are not used by any other QuerySet in this DataTableModel.
const unusedRowIds = new Set(querySet.getAllRows());
for (const qs of this._querySets) {
for (const rowId of qs.getAllRows()) {
unusedRowIds.delete(rowId);
}
}
this._tableData.unloadPartial(Array.from(unusedRowIds) as number[]);
}
}
/**
* Returns a filtering function which tells whether a row matches the given query.
*/
export function getFilterFunc(docData: DocData, query: Query): FilterFunc {
// NOTE we rely without checking on tableId and colIds being valid.
const tableData: BaseTableData = docData.getTable(query.tableId)!;
const colIds = Object.keys(query.filters).sort();
const colPairs = colIds.map(
(c) => [tableData.getRowPropFunc(c)!, new Set(query.filters[c])] as [RowPropFunc, Set<any>]);
return (rowId: RowId) => colPairs.every(([getter, values]) => values.has(getter(rowId)));
}
type RowPropFunc = (rowId: RowId) => any;
/**
* Helper that converts a Query (with tableId/colIds) to an object with tableRef/colRefs (i.e.
* rowIds), and consistently sorted. We use that to identify a Query across table/column renames.
*/
function convertQueryToRefs(docModel: DocModel, query: Query): QueryRefs {
const tableRec: any = docModel.dataTables[query.tableId].tableMetaRow;
const colRefsByColId: {[colId: string]: number} = {};
for (const col of (tableRec as any).columns.peek().peek()) {
colRefsByColId[col.colId.peek()] = col.getRowId();
}
const colIds = Object.keys(query.filters);
const filterPairs = colIds.map((c) => [colRefsByColId[c], query.filters[c]] as [number, any]);
// Keep filters sorted by colRef, for consistency.
filterPairs.sort((a, b) => nativeCompare(a[0], b[0]));
// Keep filter values sorted by value, for consistency.
filterPairs.forEach(([colRef, values]) => values.sort(nativeCompare));
return {tableRef: tableRec.getRowId(), filterPairs};
}
/**
* Helper to convert a QueryRefs (using tableRef/colRefs) object back to a Query (using
* tableId/colIds).
*/
function convertQueryFromRefs(docModel: DocModel, queryRefs: QueryRefs): Query {
const tableRec = docModel.dataTablesByRef.get(queryRefs.tableRef)!.tableMetaRow;
const filters: {[colId: string]: any[]} = {};
for (const [colRef, values] of queryRefs.filterPairs) {
filters[docModel.columns.getRowModel(colRef).colId.peek()] = values;
}
return {tableId: tableRec.tableId.peek(), filters};
}
/**
* Encodes a query (converted to QueryRefs using convertQueryToRefs()) as a string, to be usable
* as a key into a map.
*
* It uses JSON.stringify, but avoids objects since their order of keys in serialization is not
* guaranteed. This is important to produce consistent results (same query => same encoding).
*/
function encodeQuery(queryRefs: QueryRefs): string {
return JSON.stringify([queryRefs.tableRef, queryRefs.filterPairs]);
}
// Decode an encoded QueryRefs.
function decodeQuery(queryKey: string): QueryRefs {
const [tableRef, filterPairs] = JSON.parse(queryKey);
return {tableRef, filterPairs};
}
/**
* Returns a ko.computed() which turns to true when the table or any of the columns needed by the
* given query are deleted.
*/
function makeQueryInvalidComputed(docModel: DocModel, queryRefs: QueryRefs): ko.Computed<boolean> {
const tableFlag: ko.Observable<boolean> = docModel.tables.getRowModel(queryRefs.tableRef)._isDeleted;
const colFlags: Array<ko.Observable<boolean>> = queryRefs.filterPairs.map(
([colRef, values]) => docModel.columns.getRowModel(colRef)._isDeleted);
return ko.computed(() => Boolean(tableFlag() || colFlags.some((c) => c())));
}

View File

@@ -0,0 +1,338 @@
// tslint:disable:no-console
// TODO: Add documentation and clean up log statements.
import {GristDoc} from 'app/client/components/GristDoc';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {delay} from 'app/common/delay';
import {waitObs} from 'app/common/gutil';
import {TableData} from 'app/common/TableData';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
import {Disposable, Observable} from 'grainjs';
import debounce = require('lodash/debounce');
/**
* SearchModel used to maintain the state of the search UI.
*/
export interface SearchModel {
value: Observable<string>; // string in the search input
isOpen: Observable<boolean>; // indicates whether the search bar is expanded to show the input
noMatch: Observable<boolean>; // indicates if there are no search matches
isRunning: Observable<boolean>; // indicates that matching is in progress
findNext(): Promise<void>; // find next match
findPrev(): Promise<void>; // find previous match
}
interface SearchPosition {
tabIndex: number;
sectionIndex: number;
rowIndex: number;
fieldIndex: number;
}
class Stepper<T> {
public array: ReadonlyArray<T> = [];
public index: number = 0;
public inRange() {
return this.index >= 0 && this.index < this.array.length;
}
// Doing await at every step adds a ton of overhead; we can optimize by returning and waiting on
// Promises only when needed.
public next(step: number, nextArrayFunc: () => Promise<void>|void): Promise<void>|void {
this.index += step;
if (!this.inRange()) {
// If index reached the end of the array, take a step at a higher level to get a new array.
// For efficiency, only wait asynchronously if the callback returned a promise.
const p = nextArrayFunc();
if (p) {
return p.then(() => this.setStart(step));
} else {
this.setStart(step);
}
}
}
public setStart(step: number) {
this.index = step > 0 ? 0 : this.array.length - 1;
}
public get value(): T { return this.array[this.index]; }
}
/**
* Implementation of SearchModel used to construct the search UI.
*/
export class SearchModelImpl extends Disposable implements SearchModel {
public readonly value = Observable.create(this, '');
public readonly isOpen = Observable.create(this, false);
public readonly isRunning = Observable.create(this, false);
public readonly noMatch = Observable.create(this, true);
private _searchRegexp: RegExp;
private _tabStepper = new Stepper<any>();
private _sectionStepper = new Stepper<ViewSectionRec>();
private _sectionTableData: TableData;
private _rowStepper = new Stepper<number>();
private _fieldStepper = new Stepper<ViewFieldRec>();
private _fieldFormatters: BaseFormatter[];
private _startPosition: SearchPosition;
private _tabsSwitched: number = 0;
constructor(private _gristDoc: GristDoc) {
super();
// Listen to input value changes (debounced) to activate searching.
const findFirst = debounce((_value: string) => this._findFirst(_value), 100);
this.autoDispose(this.value.addListener(v => { findFirst(v); }));
}
public async findNext() {
if (this.noMatch.get()) { return; }
this._startPosition = this._getCurrentPosition();
await this._nextField(1);
return this._matchNext(1);
}
public async findPrev() {
if (this.noMatch.get()) { return; }
this._startPosition = this._getCurrentPosition();
await this._nextField(-1);
return this._matchNext(-1);
}
private _findFirst(value: string) {
if (!value) { this.noMatch.set(true); return; }
this._searchRegexp = makeRegexp(value);
const tabs: any[] = this._gristDoc.docModel.allDocPages.peek();
this._tabStepper.array = tabs;
this._tabStepper.index = tabs.findIndex(t => t.viewRef() === this._gristDoc.activeViewId.get());
if (this._tabStepper.index < 0) { this.noMatch.set(true); return; }
const view = this._tabStepper.value.view.peek();
const sections: any[] = view.viewSections().peek();
this._sectionStepper.array = sections;
this._sectionStepper.index = sections.findIndex(s => s.getRowId() === view.activeSectionId());
if (this._sectionStepper.index < 0) { this.noMatch.set(true); return; }
this._initNewSectionShown();
// Find the current cursor position in the current section.
const viewInstance = this._sectionStepper.value.viewInstance.peek()!;
const pos = viewInstance.cursor.getCursorPos();
this._rowStepper.index = pos.rowIndex!;
this._fieldStepper.index = pos.fieldIndex!;
this._startPosition = this._getCurrentPosition();
return this._matchNext(1);
}
private async _matchNext(step: number): Promise<void> {
const indicatorTimer = setTimeout(() => this.isRunning.set(true), 300);
try {
const searchRegexp = this._searchRegexp;
let count = 0;
let lastBreak = Date.now();
this._tabsSwitched = 0;
while (!this._matches() || ((await this._loadSection(step)) && !this._matches())) {
// To avoid hogging the CPU for too long, check time periodically, and if we've been running
// for long enough, take a brief break. We choose a 5ms break every 20ms; and only check
// time every 100 iterations, to avoid excessive overhead purely due to time checks.
if ((++count) % 100 === 0 && Date.now() >= lastBreak + 20) {
await delay(5);
lastBreak = Date.now();
// After other code had a chance to run, it's possible that we are now searching for
// something else, in which case abort this task.
if (this._searchRegexp !== searchRegexp) {
console.log("SearchBar: aborting search since a new one was started");
return;
}
}
const p = this._nextField(step);
if (p) { await p; }
// Detect when we get back to the start position; this is where we break on no match.
if (this._isCurrentPosition(this._startPosition)) {
console.log("SearchBar: reached start position without finding anything");
this.noMatch.set(true);
return;
}
// A fail-safe to prevent certain bugs from causing infinite loops; break also if we scan
// through tabs too many times.
// TODO: test it by disabling the check above.
if (this._tabsSwitched > this._tabStepper.array.length) {
console.log("SearchBar: aborting search due to too many tab switches");
this.noMatch.set(true);
return;
}
}
console.log("SearchBar: found a match at %s", JSON.stringify(this._getCurrentPosition()));
this.noMatch.set(false);
await this._highlight();
} finally {
clearTimeout(indicatorTimer);
this.isRunning.set(false);
}
}
private _getCurrentPosition(): SearchPosition {
// It's important to call _getCurrentPosition() in the visible tab, since other tabs will not
// use the currently visible version of the data (with the same sort and filter).
return {
tabIndex: this._tabStepper.index,
sectionIndex: this._sectionStepper.index,
rowIndex: this._rowStepper.index,
fieldIndex: this._fieldStepper.index,
};
}
private _isCurrentPosition(pos: SearchPosition): boolean {
return (
this._tabStepper.index === pos.tabIndex &&
this._sectionStepper.index === pos.sectionIndex &&
this._rowStepper.index === pos.rowIndex &&
this._fieldStepper.index === pos.fieldIndex
);
}
private _nextField(step: number): Promise<void>|void {
return this._fieldStepper.next(step, () => this._nextRow(step));
// console.log("nextField", this._fieldStepper.index);
}
private _nextRow(step: number) {
return this._rowStepper.next(step, () => this._nextSection(step));
// console.log("nextRow", this._rowStepper.index);
}
private async _nextSection(step: number) {
// Switching sections is rare enough that we don't worry about optimizing away `await` calls.
await this._sectionStepper.next(step, () => this._nextTab(step));
// console.log("nextSection", this._sectionStepper.index);
await this._initNewSectionAny();
}
// TODO There are issues with filtering. A section may have filters applied, and it may be
// auto-filtered (linked sections). If a tab is shown, we have the filtered list of rowIds; if
// the tab is not shown, it takes work to apply explicit filters. For linked sections, the
// sensible behavior seems to scan through ALL values, then once a match is found, set the
// cursor that determines the linking to include the matched row. And even that may not always
// be possible. So this is an open question.
private _initNewSectionCommon() {
const section = this._sectionStepper.value;
const tableModel = this._gristDoc.getTableModel(section.table.peek().tableId.peek());
this._sectionTableData = tableModel.tableData;
this._fieldStepper.array = section.viewFields().peek();
this._fieldFormatters = this._fieldStepper.array.map(
f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson()));
return tableModel;
}
private _initNewSectionShown() {
this._initNewSectionCommon();
const viewInstance = this._sectionStepper.value.viewInstance.peek()!;
this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[];
}
private async _initNewSectionAny() {
const tableModel = this._initNewSectionCommon();
const viewInstance = this._sectionStepper.value.viewInstance.peek();
if (viewInstance) {
this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[];
} else {
// If we are searching through another tab (not currently loaded), we will NOT have a
// viewInstance, but we use the unsorted unfiltered row list, and if we find a match, the
// _loadSection() method will load the tab and we'll repeat the search with a viewInstance.
await tableModel.fetch();
this._rowStepper.array = this._sectionTableData.getRowIds();
}
}
private async _nextTab(step: number) {
await this._tabStepper.next(step, () => undefined);
this._tabsSwitched++;
// console.log("nextTab", this._tabStepper.index);
const view = this._tabStepper.value.view.peek();
this._sectionStepper.array = view.viewSections().peek();
}
private _matches(): boolean {
if (this._tabStepper.index < 0 || this._sectionStepper.index < 0 ||
this._rowStepper.index < 0 || this._fieldStepper.index < 0) {
console.warn("match outside");
return false;
}
const field = this._fieldStepper.value;
const formatter = this._fieldFormatters[this._fieldStepper.index];
const rowId = this._rowStepper.value;
const displayCol = field.displayColModel.peek();
const value = this._sectionTableData.getValue(rowId, displayCol.colId.peek());
// TODO: Note that formatting dates is now the bulk of the performance cost.
const text = formatter.format(value);
return this._searchRegexp.test(text);
}
private async _loadSection(step: number): Promise<boolean> {
// If we found a match in a section for which we don't have a valid BaseView instance, we need
// to load the BaseView and start searching the section again, since the match we found does
// not take into account sort or filters. So we switch to the right tab, wait for the
// viewInstance to be created, reset the section info, and return true to continue searching.
const section = this._sectionStepper.value;
if (!section.viewInstance.peek()) {
const view = this._tabStepper.value.view.peek();
await this._gristDoc.openDocPage(view.getRowId());
console.log("SearchBar: loading view %s section %s", view.getRowId(), section.getRowId());
const viewInstance: any = await waitObs(section.viewInstance);
await viewInstance.getLoadingDonePromise();
this._initNewSectionShown();
this._rowStepper.setStart(step);
this._fieldStepper.setStart(step);
console.log("SearchBar: loaded view %s section %s", view.getRowId(), section.getRowId());
return true;
}
return false;
}
// Highlights the cell at the current position.
private async _highlight() {
const view = this._tabStepper.value.view.peek();
await this._gristDoc.openDocPage(view.getRowId());
const section = this._sectionStepper.value;
view.activeSectionId(section.getRowId());
// We may need to wait for the BaseView instance to load.
const viewInstance = await waitObs<any>(section.viewInstance);
await viewInstance.getLoadingDonePromise();
viewInstance.setCursorPos({
rowIndex: this._rowStepper.index,
fieldIndex: this._fieldStepper.index,
});
// Highlight the selected cursor, after giving it a chance to update. We find the cursor in
// this ad-hoc way rather than use observables, to avoid the overhead of *every* cell
// depending on an additional observable.
await delay(0);
const cursor = viewInstance.viewPane.querySelector('.selected_cursor');
if (cursor) {
cursor.classList.add('search-match');
setTimeout(() => cursor.classList.remove('search-match'), 20);
}
}
}
function makeRegexp(value: string) {
// From https://stackoverflow.com/a/3561711/328565
const escaped = value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
return new RegExp(escaped, 'i');
}

View File

@@ -0,0 +1,92 @@
import {KoArray} from 'app/client/lib/koArray';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {FilterFunc, RowId} from 'app/client/models/rowset';
import {TableData} from 'app/client/models/TableData';
import {CellValue} from 'app/common/DocActions';
import {Computed, Disposable, MutableObsArray, obsArray, Observable} from 'grainjs';
import {ColumnFilter, ColumnFilterFunc, getFilterFunc} from './ColumnFilter';
type RowValueFunc = (rowId: RowId) => CellValue;
interface OpenColumnFilter {
fieldRef: number;
colFilter: ColumnFilter;
}
function buildColFunc(getter: RowValueFunc, filterFunc: ColumnFilterFunc): FilterFunc {
return (rowId: RowId) => filterFunc(getter(rowId));
}
/**
* SectionFilter represents a collection of column filters in place for a view section. It is created
* out of `viewFields` and `tableData`, and provides a Computed `sectionFilterFunc` that users can
* subscribe to in order to update their FilteredRowSource.
*
* Additionally, `setFilterOverride()` provides a way to override the current filter for a given colRef,
* to reflect the changes in an open filter dialog. Also, `addTemporaryRow()` allows to add a rowId
* that should be present regardless of filters. These rows are removed automatically when an update to the filter
* results in their being displayed (obviating the need to maintain their rowId explicitly).
*/
export class SectionFilter extends Disposable {
public readonly sectionFilterFunc: Observable<FilterFunc>;
private _openFilterOverride: Observable<OpenColumnFilter|null> = Observable.create(this, null);
private _tempRows: MutableObsArray<RowId> = obsArray();
constructor(viewFields: ko.Computed<KoArray<ViewFieldRec>>, tableData: TableData) {
super();
const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {
const fields = use(use(viewFields).getObservable());
const funcs: Array<FilterFunc | null> = fields.map(f => {
const filterFunc = (openFilter && openFilter.fieldRef === f.getRowId()) ?
use(openFilter.colFilter.filterFunc) :
getFilterFunc(use(f.activeFilter));
const getter = tableData.getRowPropFunc(use(f.colId));
if (!filterFunc || !getter) { return null; }
return buildColFunc(getter as RowValueFunc, filterFunc);
})
.filter(f => f !== null); // Filter out columns that don't have a filter
return (rowId: RowId) => funcs.every(f => Boolean(f && f(rowId)));
});
this.sectionFilterFunc = Computed.create(this, columnFilterFunc, this._tempRows,
(_use, filterFunc, tempRows) => {
return (rowId: RowId) => tempRows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId);
});
// Prune temporary rowIds that are no longer being filtered out.
this.autoDispose(columnFilterFunc.addListener(f => {
this._tempRows.set(this._tempRows.get().filter(rowId => !f(rowId)));
}));
}
/**
* Allows to override a single filter for a given fieldRef. Multiple calls to `setFilterOverride` will overwrite
* previously set values.
*/
public setFilterOverride(fieldRef: number, colFilter: ColumnFilter) {
this._openFilterOverride.set(({fieldRef, colFilter}));
colFilter.onDispose(() => {
const override = this._openFilterOverride.get();
if (override && override.colFilter === colFilter) {
this._openFilterOverride.set(null);
}
});
}
public addTemporaryRow(rowId: number) {
// Only add the rowId if it would otherwise be filtered out
if (!this.sectionFilterFunc.get()(rowId)) {
this._tempRows.push(rowId);
}
}
public resetTemporaryRows() {
this._tempRows.set([]);
}
}

View File

@@ -0,0 +1,141 @@
/**
* TableData maintains a single table's data.
*/
import {ColumnACIndexes} from 'app/client/models/ColumnACIndexes';
import {DocData} from 'app/client/models/DocData';
import {DocAction, ReplaceTableData, TableDataAction, UserAction} from 'app/common/DocActions';
import {ColTypeMap, TableData as BaseTableData} from 'app/common/TableData';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {Emitter} from 'grainjs';
export type SearchFunc = (value: string) => boolean;
/**
* TableData class to maintain a single table's data.
*/
export class TableData extends BaseTableData {
public readonly tableActionEmitter = new Emitter();
public readonly dataLoadedEmitter = new Emitter();
public readonly columnACIndexes = new ColumnACIndexes(this);
/**
* Constructor for TableData.
* @param {DocData} docData: The root DocData object for this document.
* @param {String} tableId: The name of this table.
* @param {Object} tableData: An object equivalent to BulkAddRecord, i.e.
* ["TableData", tableId, rowIds, columnValues].
* @param {Object} columnTypes: A map of colId to colType.
*/
constructor(public readonly docData: DocData,
tableId: string, tableData: TableDataAction|null, columnTypes: ColTypeMap) {
super(tableId, tableData, columnTypes);
}
public loadData(tableData: TableDataAction|ReplaceTableData): number[] {
const oldRowIds = super.loadData(tableData);
// If called from base constructor, this.dataLoadedEmitter may be unset; in that case there
// are no subscribers anyway.
if (this.dataLoadedEmitter) {
this.dataLoadedEmitter.emit(oldRowIds, this.getRowIds());
}
return oldRowIds;
}
// Used by QuerySet to load new rows for onDemand tables.
public loadPartial(data: TableDataAction): void {
super.loadPartial(data);
// Emit dataLoaded event, to trigger ('rowChange', 'add') on the TableModel RowSource.
this.dataLoadedEmitter.emit([], data[2]);
}
// Used by QuerySet to remove unused rows for onDemand tables when a QuerySet is disposed.
public unloadPartial(rowIds: number[]): void {
super.unloadPartial(rowIds);
// Emit dataLoaded event, to trigger ('rowChange', 'rm') on the TableModel RowSource.
this.dataLoadedEmitter.emit(rowIds, []);
}
/**
* Given a colId and a search string, returns a list of matches, optionally limiting their number.
* The matches are returned as { label, value } pairs, for use with auto-complete. In these, value
* is the rowId, and label is the actual value matching the query.
* @param {String} colId: identifies the column to search.
* @param {String|Function} searchTextOrFunc: If a string, then the text to search. It splits the
* text into words, and returns values which contain each of the words. May be a function
* which, given a formatted column value, returns whether to include it.
* @param [Number] optMaxResults: if given, limit the number of returned results to this.
* @returns Array[{label, value}] array of objects, suitable for use with JQueryUI's autocomplete.
*/
public columnSearch(colId: string, formatter: BaseFormatter,
searchTextOrFunc: string|SearchFunc, optMaxResults?: number) {
// Search for each of the words in query, case-insensitively.
const searchFunc = (typeof searchTextOrFunc === 'function' ? searchTextOrFunc :
makeSearchFunc(searchTextOrFunc));
const maxResults = optMaxResults || Number.POSITIVE_INFINITY;
const rowIds = this.getRowIds();
const valColumn = this.getColValues(colId);
const ret = [];
if (!valColumn) {
// tslint:disable-next-line:no-console
console.warn(`TableData.columnSearch called on invalid column ${this.tableId}.${colId}`);
} else {
for (let i = 0; i < rowIds.length && ret.length < maxResults; i++) {
const rowId = rowIds[i];
const value = String(formatter.formatAny(valColumn[i]));
if (value && searchFunc(value)) {
ret.push({ label: value, value: rowId });
}
}
}
return ret;
}
/**
* Sends an array of table-specific action to the server to be applied. The tableId should be
* omitted from each `action` parameter and will be inserted automatically.
*
* @param {Array} actions: Array of user actions of the form [actionType, rowId, etc], which is sent
* to the server as [actionType, **tableId**, rowId, etc]
* @param {String} optDesc: Optional description of the actions to be shown in the log.
* @returns {Array} Array of return values for all the UserActions as produced by the data engine.
*/
public sendTableActions(actions: UserAction[], optDesc?: string) {
actions.forEach((action) => action.splice(1, 0, this.tableId));
return this.docData.sendActions(actions as DocAction[], optDesc);
}
/**
* Sends a table-specific action to the server. The tableId should be omitted from the action parameter
* and will be inserted automatically.
*
* @param {Array} action: [actionType, rowId...], sent as [actionType, **tableId**, rowId...]
* @param {String} optDesc: Optional description of the actions to be shown in the log.
* @returns {Object} Return value for the UserAction as produced by the data engine.
*/
public sendTableAction(action: UserAction, optDesc?: string) {
if (!action) { return; }
action.splice(1, 0, this.tableId);
return this.docData.sendAction(action as DocAction, optDesc);
}
/**
* Emits a table-specific action received from the server as a 'tableAction' event.
*/
public receiveAction(action: DocAction): boolean {
const applied = super.receiveAction(action);
if (applied) {
this.tableActionEmitter.emit(action);
}
return applied;
}
}
function makeSearchFunc(searchText: string): SearchFunc {
const searchWords = searchText.toLowerCase().split(/\s+/);
return value => {
const lower = value.toLowerCase();
return searchWords.every(w => lower.includes(w));
};
}

View File

@@ -0,0 +1,114 @@
/**
* TableModel maintains the model for an arbitrary data table of a Grist document.
*/
var _ = require('underscore');
var ko = require('knockout');
var dispose = require('../lib/dispose');
var rowset = require('./rowset');
var modelUtil = require('./modelUtil');
function TableModel(docModel, tableData) {
this.docModel = docModel;
this.tableData = tableData;
// Maps groupBy fields to RowGrouping objects.
this.rowGroupings = {};
this.isLoaded = ko.observable(tableData.isLoaded);
this.autoDispose(tableData.dataLoadedEmitter.addListener(this.onDataLoaded, this));
this.autoDispose(tableData.tableActionEmitter.addListener(this.dispatchAction, this));
}
dispose.makeDisposable(TableModel);
_.extend(TableModel.prototype, rowset.RowSource.prototype, modelUtil.ActionDispatcher);
TableModel.prototype.fetch = function(force) {
if (this.isLoaded.peek() && force) {
this.isLoaded(false);
}
return this.tableData.docData.fetchTable(this.tableData.tableId, force);
};
TableModel.prototype.getAllRows = function() {
return this.tableData.getRowIds();
};
TableModel.prototype.getRowGrouping = function(groupByCol) {
var grouping = this.rowGroupings[groupByCol];
if (!grouping) {
grouping = rowset.RowGrouping.create(null, this.tableData.getRowPropFunc(groupByCol));
grouping.subscribeTo(this);
this.rowGroupings[groupByCol] = grouping;
}
return grouping;
};
TableModel.prototype.onDataLoaded = function(oldRowIds, newRowIds) {
this.trigger('rowChange', 'remove', oldRowIds);
this.trigger('rowChange', 'add', newRowIds);
this.isLoaded(true);
};
/**
* Shortcut for `.tableData.sendTableActions`. See documentation in TableData.js.
*/
TableModel.prototype.sendTableActions = function(actions, optDesc) {
return this.tableData.sendTableActions(actions, optDesc);
};
/**
* Shortcut for `.tableData.sendTableAction`. See documentation in TableData.js.
*/
TableModel.prototype.sendTableAction = function(action, optDesc) {
return this.tableData.sendTableAction(action, optDesc);
};
//----------------------------------------------------------------------
/**
* Called via `this.dispatchAction`.
*/
TableModel.prototype._process_AddRecord = function(action, tableId, rowId, columnValues) {
this.trigger('rowChange', 'add', [rowId]);
};
TableModel.prototype._process_RemoveRecord = function(action, tableId, rowId) {
this.trigger('rowChange', 'remove', [rowId]);
};
TableModel.prototype._process_UpdateRecord = function(action, tableId, rowId, columnValues) {
this.trigger('rowChange', 'update', [rowId]);
this.trigger('rowNotify', [rowId], action);
};
TableModel.prototype._process_ReplaceTableData = function() {
// No-op because TableData.js already translates ReplaceTableData to a 'dataLoaded' event.
};
TableModel.prototype._process_BulkAddRecord = function(action, tableId, rowIds, columns) {
this.trigger('rowChange', 'add', rowIds);
};
TableModel.prototype._process_BulkRemoveRecord = function(action, tableId, rowIds) {
this.trigger('rowChange', 'remove', rowIds);
};
TableModel.prototype._process_BulkUpdateRecord = function(action, tableId, rowIds, columns) {
this.trigger('rowChange', 'update', rowIds);
this.trigger('rowNotify', rowIds, action);
};
// All schema changes to this table should be forwarded to each row.
// TODO: we may need to worry about groupings (e.g. recreate the grouping function) once we do row
// groupings of user data. Metadata isn't subject to schema changes, so that doesn't matter.
TableModel.prototype.applySchemaAction = function(action) {
this.trigger('rowNotify', rowset.ALL, action);
};
TableModel.prototype._process_AddColumn = function(action) { this.applySchemaAction(action); };
TableModel.prototype._process_RemoveColumn = function(action) { this.applySchemaAction(action); };
TableModel.prototype._process_RenameColumn = function(action) { this.applySchemaAction(action); };
TableModel.prototype._process_ModifyColumn = function(action) { this.applySchemaAction(action); };
TableModel.prototype._process_RenameTable = _.noop;
TableModel.prototype._process_RemoveTable = _.noop;
module.exports = TableModel;

View File

@@ -0,0 +1,261 @@
/**
* This module exposes the various interface that describes the model to generate a tree view. It
* provides also a way to create a TreeModel from a grist table that implements the tree view
* interface (ie: a table with both an .indentation and .pagePos fields).
*
* To use with tableData;
* > fromTableData(tableData, (rec) => dom('div', rec.label))
*
* Optionally you can build a model by reusing items from an old model with matching records
* ids. The is useful to benefit from dom reuse of the TreeViewComponent which allow to persist
* state when the model updates.
*
*/
import { insertPositions } from "app/client/lib/tableUtil";
import { BulkColValues } from "app/common/DocActions";
import { nativeCompare } from "app/common/gutil";
import { obsArray, ObsArray } from "grainjs";
import forEach = require("lodash/forEach");
import forEachRight = require("lodash/forEachRight");
import reverse = require("lodash/reverse");
import { TableData } from "./TableData";
/**
* A generic definition of a tree to use with the `TreeViewComponent`. The tree implements
* `TreeModel` and any item in it implements `TreeItem`.
*/
export interface TreeNode {
// Returns an observable array of children. Or null if the node does not accept children.
children(): ObsArray<TreeItem>|null;
// Inserts newChild as a child, before nextChild, or at the end if nextChild is null. If
// newChild is already in the tree, it is the implementer's responsibility to remove it from the
// children() list of its old parent.
insertBefore(newChild: TreeItem, nextChild: TreeItem|null): void;
// Removes child from the list of children().
removeChild(child: TreeItem): void;
}
export interface TreeItem extends TreeNode {
// Returns the DOM element to render for this tree node.
buildDom(): HTMLElement;
}
export interface TreeModel extends TreeNode {
children(): ObsArray<TreeItem>;
}
// A tree record has an id and an indentation field.
export interface TreeRecord {
id: number;
indentation: number;
pagePos: number;
[key: string]: any;
}
// describes a function that builds dom for a particular record
type DomBuilder = (id: number) => HTMLElement;
// Returns a list of the records from table that is suitable to build the tree model, ie: records
// are sorted by .posKey, and .indentation starts at 0 for the first records and can only increase
// one step at a time (but can decrease as much as you want).
function getRecords(table: TableData) {
const records = (table.getRecords() as TreeRecord[])
.sort((a, b) => nativeCompare(a.pagePos, b.pagePos));
return fixIndents(records);
}
// The fixIndents function returns a copy of records with the garantee the .indentation starts at 0
// and can only increase one step at a time (note that it is however permitted to decrease several
// level at a time). This is useful to build a model for the tree view.
export function fixIndents(records: TreeRecord[]) {
let maxNextIndent = 0;
return records.map((rec, index) => {
const indentation = Math.min(maxNextIndent, rec.indentation);
maxNextIndent = indentation + 1;
return {...rec, indentation};
}) as TreeRecord[];
}
// build a tree model from a grist table storing tree view data
export function fromTableData(table: TableData, buildDom: DomBuilder, oldModel?: TreeModelRecord) {
const records = getRecords(table);
const storage = {table, records};
// an object to collect items at all level of indentations
const indentations = {} as {[ind: number]: TreeItemRecord[]};
// a object that map record ids to old items
const oldItems = {} as {[id: number]: TreeItemRecord};
if (oldModel) {
walkTree(oldModel, (item: TreeItemRecord) => oldItems[item.record.id] = item);
}
// Let's iterate from bottom to top so that when we visit an item we've already built all of its
// children. For each record reuses an old item if there is one with same record id.
forEachRight(records, (rec, index) => {
const siblings = indentations[rec.indentation] = indentations[rec.indentation] || [];
const children = indentations[rec.indentation + 1] || [];
delete indentations[rec.indentation + 1];
const item = oldItems[rec.id] || new TreeItemRecord();
item.init(storage, index, reverse(children));
item.buildDom = () => buildDom(rec.id);
siblings.push(item);
});
return new TreeModelRecord(storage, reverse(indentations[0] || []));
}
// a table data with all of its records as returned by getRecords(tableData)
interface Storage {
table: TableData;
records: TreeRecord[];
}
// TreeNode implementation that uses a grist table.
export class TreeNodeRecord implements TreeNode {
public storage: Storage;
public index: number|"root";
public children: () => ObsArray<TreeItemRecord>;
private _children: TreeItemRecord[];
constructor() {
// nothing here
}
public init(storage: Storage, index: number|"root", children: TreeItemRecord[]) {
this.storage = storage;
this.index = index;
this._children = children;
const obsChildren = obsArray(this._children);
this.children = () => obsChildren;
}
// Moves 'item' along with all its descendant to just before 'nextChild' by updating the
// .indentation and .position fields of all of their corresponding records in the table.
public async insertBefore(item: TreeItemRecord, nextChild: TreeItemRecord|null) {
// get records for newItem and its descendants
const records = item.getRecords();
if (records.length) {
// adjust indentation for the records
const indent = this.index === "root" ? 0 : this._records[this.index].indentation + 1;
const indentations = records.map((rec, i) => rec.indentation + indent - records[0].indentation);
// adjust positions
let upperPos, lowerPos: number|null;
if (nextChild) {
const index = nextChild.index;
upperPos = this._records[index].pagePos;
lowerPos = index ? this._records[index - 1].pagePos : null;
} else {
const lastIndex = this.findLastIndex();
if (lastIndex !== "root") {
upperPos = (this._records[lastIndex + 1] || {pagePos: null}).pagePos;
lowerPos = this._records[lastIndex].pagePos;
} else {
upperPos = lowerPos = null;
}
}
const positions = insertPositions(lowerPos, upperPos, records.length);
// do update
const update = records.map((rec, i) => ({...rec, indentation: indentations[i], pagePos: positions[i]}));
await this.sendActions({update});
}
}
// Sends user actions to update [A, B, ...] and remove [C, D, ...] when called with
// `{update: [A, B ...], remove: [C, D, ...]}`.
public async sendActions(actions: {update?: TreeRecord[], remove?: TreeRecord[]}) {
const update = actions.update || [];
const remove = actions.remove || [];
const userActions = [];
if (update.length) {
const values = {} as BulkColValues;
// let's transpose [{key1: "val1", ...}, ...] to {key1: ["val1", ...], ...}
forEach(update[0], (val, key) => values[key] = update.map(rec => rec[key]));
const rowIds = values.id;
delete values.id;
userActions.push(["BulkUpdateRecord", rowIds, values]);
}
if (remove.length) {
userActions.push(["BulkRemove", remove.map(rec => rec.id)]);
}
if (userActions.length) {
await this.storage.table.sendTableActions(userActions);
}
}
// Removes child.
public async removeChild(child: TreeItemRecord) {
await this.sendActions({remove: child.getRecords()});
}
// Get all the records included in this item.
public getRecords(): TreeRecord[] {
const records = [] as TreeRecord[];
if (this.index !== "root") {records.push(this._records[this.index]); }
walkTree(this, (item: TreeItemRecord) => records.push(this._records[item.index]));
return records;
}
public findLastIndex(): number|"root" {
return this._children.length ? this._children[this._children.length - 1].findLastIndex() : this.index;
}
private get _records() {
return this.storage.records;
}
}
export class TreeItemRecord extends TreeNodeRecord implements TreeItem {
public index: number;
public buildDom: () => HTMLElement;
constructor() {
super();
}
public get record() { return this.storage.records[this.index]; }
}
export class TreeModelRecord extends TreeNodeRecord implements TreeModel {
constructor(storage: Storage, children: TreeItemRecord[]) {
super();
this.init(storage, "root", children);
}
}
export function walkTree<T extends TreeItem>(model: TreeNode, func: (item: T) => void): void;
export function walkTree(model: TreeNode, func: (item: TreeItem) => void) {
const children = model.children();
if (children) {
for (const child of children.get()) {
func(child);
walkTree(child, func);
}
}
}
export function find<T extends TreeItem>(model: TreeNode, func: (item: T) => boolean): T|undefined;
export function find(model: TreeNode, func: (item: TreeItem) => boolean): TreeItem|undefined {
const children = model.children();
if (children) {
for (const child of children.get()) {
const found = func(child) && child || find(child, func);
if (found) {return found; }
}
}
}

View File

@@ -0,0 +1,316 @@
import {normalizeEmail} from 'app/common/emails';
import {GristLoadConfig} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, PermissionData, PermissionDelta, UserAPI} from 'app/common/UserAPI';
import {computed, Computed, Disposable, obsArray, ObsArray, observable, Observable} from 'grainjs';
import some = require('lodash/some');
export interface UserManagerModel {
initData: PermissionData; // PermissionData used to initialize the UserManager
resourceType: ResourceType; // String representing the access resource
userSelectOptions: IMemberSelectOption[]; // Select options for each user's role dropdown
orgUserSelectOptions: IOrgMemberSelectOption[]; // Select options for each user's role dropdown on the org
inheritSelectOptions: IMemberSelectOption[]; // Select options for the maxInheritedRole dropdown
maxInheritedRole: Observable<roles.BasicRole|null>; // Current unsaved maxInheritedRole setting
membersEdited: ObsArray<IEditableMember>; // Current unsaved editable array of members
publicMember: IEditableMember|null; // Member whose access (VIEWER or null) represents that of
// anon@ or everyone@ (depending on the settings and resource).
isAnythingChanged: Computed<boolean>; // Indicates whether there are unsaved changes
isOrg: boolean; // Indicates if the UserManager is for an org
// Resets all unsaved changes
reset(): void;
// Writes all unsaved changes to the server.
save(userApi: UserAPI, resourceId: number|string): Promise<void>;
// Adds a member to membersEdited
add(email: string, role: roles.Role|null): void;
// Removes a member from membersEdited
remove(member: IEditableMember): void;
// Returns a boolean indicating if the member is the currently active user.
isActiveUser(member: IEditableMember): boolean;
// Returns the PermissionDelta reflecting the current unsaved changes in the model.
getDelta(): PermissionDelta;
}
export type ResourceType = 'organization'|'workspace'|'document';
export interface IEditableMember {
id: number; // Newly invited members do not have ids and are represented by -1
name: string;
email: string;
picture?: string|null;
access: Observable<roles.Role|null>;
parentAccess: roles.BasicRole|null;
inheritedAccess: Computed<roles.BasicRole|null>;
effectiveAccess: Computed<roles.Role|null>;
origAccess: roles.Role|null;
isNew: boolean;
isRemoved: boolean;
}
// An option for the select elements used in the UserManager.
export interface IMemberSelectOption {
value: roles.BasicRole|null;
label: string;
}
// An option for the organization select elements used in the UserManager.
export interface IOrgMemberSelectOption {
value: roles.NonGuestRole|null;
label: string;
}
interface IBuildMemberOptions {
id: number;
name: string;
email: string;
picture?: string|null;
access: roles.Role|null;
parentAccess: roles.BasicRole|null;
}
/**
*
*/
export class UserManagerModelImpl extends Disposable implements UserManagerModel {
// Select options for each individual user's role dropdown.
public readonly userSelectOptions: IMemberSelectOption[] = [
{ value: roles.OWNER, label: 'Owner' },
{ value: roles.EDITOR, label: 'Editor' },
{ value: roles.VIEWER, label: 'Viewer' }
];
// Select options for each individual user's role dropdown in the org.
public readonly orgUserSelectOptions: IOrgMemberSelectOption[] = [
{ value: roles.OWNER, label: 'Owner' },
{ value: roles.EDITOR, label: 'Editor' },
{ value: roles.VIEWER, label: 'Viewer' },
{ value: roles.MEMBER, label: 'No Default Access' },
];
// Select options for the resource's maxInheritedRole dropdown.
public readonly inheritSelectOptions: IMemberSelectOption[] = [
{ value: roles.OWNER, label: 'In Full' },
{ value: roles.EDITOR, label: 'View & Edit' },
{ value: roles.VIEWER, label: 'View Only' },
{ value: null, label: 'None' }
];
public maxInheritedRole: Observable<roles.BasicRole|null> =
observable(this.initData.maxInheritedRole || null);
// The public member's access settings reflect either those of anonymous users (when
// shouldSupportAnon() is true) or those of everyone@ (i.e. access granted to all users,
// supported for docs only). The member is null when public access is not supported.
public publicMember: IEditableMember|null = this._buildPublicMember();
public membersEdited = this.autoDispose(obsArray<IEditableMember>(this._buildAllMembers()));
public isOrg: boolean = this.resourceType === 'organization';
// Checks if any members were added/removed/changed, if the max inherited role changed or if the
// anonymous access setting changed to enable the confirm button to write changes to the server.
public readonly isAnythingChanged: Computed<boolean> = this.autoDispose(computed<boolean>((use) => {
const isMemberChangedFn = (m: IEditableMember) => m.isNew || m.isRemoved ||
use(m.access) !== m.origAccess;
const isInheritanceChanged = !this.isOrg && use(this.maxInheritedRole) !== this.initData.maxInheritedRole;
return some(use(this.membersEdited), isMemberChangedFn) || isInheritanceChanged ||
(this.publicMember ? isMemberChangedFn(this.publicMember) : false);
}));
constructor(
public initData: PermissionData,
public resourceType: ResourceType,
private _activeUserEmail: string|null
) {
super();
}
public reset(): void {
this.membersEdited.set(this._buildAllMembers());
}
public async save(userApi: UserAPI, resourceId: number|string): Promise<void> {
if (this.resourceType === 'organization') {
await userApi.updateOrgPermissions(resourceId as number, this.getDelta());
} else if (this.resourceType === 'workspace') {
await userApi.updateWorkspacePermissions(resourceId as number, this.getDelta());
} else if (this.resourceType === 'document') {
await userApi.updateDocPermissions(resourceId as string, this.getDelta());
}
}
public add(email: string, role: roles.Role|null): void {
email = normalizeEmail(email);
const members = this.membersEdited.get();
const index = members.findIndex((m) => m.email === email);
const existing = index > -1 ? members[index] : null;
if (existing && existing.isRemoved) {
// The member is replaced with the isRemoved set to false to trigger an
// update to the membersEdited observable array.
this.membersEdited.splice(index, 1, {...existing, isRemoved: false});
} else if (existing) {
const effective = existing.effectiveAccess.get();
if (effective && effective !== roles.GUEST) {
// If the member is visible, throw to inform the user.
throw new Error("This user is already in the list");
}
// If the member exists but is not visible, update their access to make them visible.
// They should be treated as a new user - removing them should make them invisible again.
existing.access.set(role);
existing.isNew = true;
} else {
const newMember = this._buildEditableMember({
id: -1, // Use a placeholder for the unknown userId
email,
name: "",
access: role,
parentAccess: null
});
newMember.isNew = true;
this.membersEdited.push(newMember);
}
}
public remove(member: IEditableMember): void {
const index = this.membersEdited.get().indexOf(member);
if (member.isNew) {
this.membersEdited.splice(index, 1);
} else {
// Keep it in the array with a flag, to simplify comparing "before" and "after" arrays.
this.membersEdited.splice(index, 1, {...member, isRemoved: true});
}
}
public isActiveUser(member: IEditableMember): boolean {
return member.email === this._activeUserEmail;
}
public getDelta(): PermissionDelta {
// Construct the permission delta from the changed users/maxInheritedRole.
const delta: PermissionDelta = { users: {} };
if (this.resourceType !== 'organization') {
const maxInheritedRole = this.maxInheritedRole.get();
if (this.initData.maxInheritedRole !== maxInheritedRole) {
delta.maxInheritedRole = maxInheritedRole;
}
}
// Looping through the members has the side effect of updating the delta.
const members = [...this.membersEdited.get()];
if (this.publicMember) {
members.push(this.publicMember);
}
members.forEach((m, i) => {
const access = m.access.get();
if (!roles.isValidRole(access)) {
throw new Error(`Cannot update user to invalid role ${access}`);
}
if (m.isNew || m.isRemoved || m.origAccess !== access) {
// Add users whose access changed.
delta.users![m.email] = m.isRemoved ? null : access as roles.NonGuestRole;
}
});
return delta;
}
private _buildAllMembers(): IEditableMember[] {
// If the UI supports some public access, strip the supported public user (anon@ or
// everyone@). Otherwise, keep it, to allow the non-fancy way of adding/removing public access.
let users = this.initData.users;
const publicMember = this.publicMember;
if (publicMember) {
users = users.filter(m => m.email !== publicMember.email);
}
return users.map(m =>
this._buildEditableMember({
id: m.id,
email: m.email,
name: m.name,
picture: m.picture,
access: m.access,
parentAccess: m.parentAccess || null,
})
);
}
private _buildPublicMember(): IEditableMember|null {
// shouldSupportAnon() changes "public" access to "anonymous" access, and enables it for
// workspaces and org level. It's currently used for on-premise installs only.
// TODO Think through proper public sharing or workspaces/orgs, and get rid of
// shouldSupportAnon() exceptions.
const email =
shouldSupportAnon() ? ANONYMOUS_USER_EMAIL :
(this.resourceType === 'document') ? EVERYONE_EMAIL : null;
if (!email) { return null; }
const user = this.initData.users.find(m => m.email === email);
return this._buildEditableMember({
id: user ? user.id : -1,
email,
name: "",
access: user ? user.access : null,
parentAccess: user ? (user.parentAccess || null) : null,
});
}
private _buildEditableMember(member: IBuildMemberOptions): IEditableMember {
// Represents the member's access set specifically on the resource of interest.
const access = Observable.create(this, member.access);
let inheritedAccess: Computed<roles.BasicRole|null>;
if (member.email === this._activeUserEmail) {
// Note that we currently prevent the active user's role from changing to prevent users from
// locking themselves out of resources. We ensure that by setting inheritedAccess to the
// active user's initial access level, which is OWNER normally. (It's sometimes possible to
// open UserManager by a less-privileged user, e.g. if access was just lowered, in which
// case any attempted changes will fail on saving.)
const initInheritedAccess = roles.getWeakestRole(member.parentAccess, this.initData.maxInheritedRole || null);
const initialAccess = roles.getStrongestRole(member.access, initInheritedAccess);
const initialAccessBasicRole = roles.getEffectiveRole(initialAccess);
// This pretends to be a computed to match the other case, but is really a constant.
inheritedAccess = Computed.create(this, (use) => initialAccessBasicRole);
} else {
// Gives the role inherited from parent taking the maxInheritedRole into account.
inheritedAccess = Computed.create(this, this.maxInheritedRole, (use, maxInherited) =>
roles.getWeakestRole(member.parentAccess, maxInherited));
}
// Gives the effective role of the member on the resource, taking everything into account.
const effectiveAccess = Computed.create(this, (use) =>
roles.getStrongestRole(use(access), use(inheritedAccess)));
effectiveAccess.onWrite((value) => {
// For UI simplicity, we use a single dropdown to represent the effective access level of
// the user AND to allow changing it. As a result, we do NOT allow using the dropdown to
// write/show values that provide less direct access than what the user already inherits.
// It is confusing to show and results in no change in the effective access.
const inherited = inheritedAccess.get();
const isAboveInherit = roles.getStrongestRole(value, inherited) !== inherited;
access.set(isAboveInherit ? value : null);
});
return {
id: member.id,
email: member.email,
name: member.name,
picture: member.picture,
access,
parentAccess: member.parentAccess || null,
inheritedAccess,
effectiveAccess,
origAccess: member.access,
isNew: false,
isRemoved: false,
};
}
}
export function getResourceParent(resource: ResourceType): ResourceType|null {
if (resource === 'workspace') {
return 'organization';
} else if (resource === 'document') {
return 'workspace';
} else {
return null;
}
}
// Check whether anon should be supported in the UI
export function shouldSupportAnon(): boolean {
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
return gristConfig.supportAnon || false;
}

View File

@@ -0,0 +1,41 @@
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {AppModel} from 'app/client/models/AppModel';
import {UserOrgPrefs} from 'app/common/Prefs';
import {Computed, Observable} from 'grainjs';
/**
* Creates an observable that returns UserOrgPrefs, and which stores them when set.
*
* For anon user, the prefs live in localStorage. Note that the observable isn't actually watching
* for changes on the server, it will only change when set.
*/
export function getUserOrgPrefsObs(appModel: AppModel): Observable<UserOrgPrefs> {
const savedPrefs = appModel.currentValidUser ? appModel.currentOrg?.userOrgPrefs : undefined;
if (savedPrefs) {
const prefsObs = Observable.create<UserOrgPrefs>(null, savedPrefs);
return Computed.create(null, (use) => use(prefsObs))
.onWrite(userOrgPrefs => {
prefsObs.set(userOrgPrefs);
return appModel.api.updateOrg('current', {userOrgPrefs});
});
} else {
const userId = appModel.currentUser?.id || 0;
const jsonPrefsObs = localStorageObs(`userOrgPrefs:u=${userId}`);
return Computed.create(null, jsonPrefsObs, (use, p) => (p && JSON.parse(p) || {}) as UserOrgPrefs)
.onWrite(userOrgPrefs => {
jsonPrefsObs.set(JSON.stringify(userOrgPrefs));
});
}
}
/**
* Creates an observable that returns a particular preference value from UserOrgPrefs, and which
* stores it when set.
*/
export function getUserOrgPrefObs<Name extends keyof UserOrgPrefs>(
appModel: AppModel, prefName: Name
): Observable<UserOrgPrefs[Name]> {
const prefsObs = getUserOrgPrefsObs(appModel);
return Computed.create(null, (use) => use(prefsObs)[prefName])
.onWrite(value => prefsObs.set({...prefsObs.get(), [prefName]: value}));
}

View File

@@ -0,0 +1,55 @@
/**
* Helpers needed for showing the title of a workspace.
*/
import {AppModel} from 'app/client/models/AppModel';
import {FullUser} from 'app/common/LoginSessionAPI';
import {Workspace} from 'app/common/UserAPI';
// Render the name of a workspace. There is a similar method in HomeLeftPane.
// Not merging since the styling of parts of the name may need to diverge.
export function workspaceName(app: AppModel, ws: Workspace) {
const {owner, name} = getWorkspaceInfo(app, ws);
return [name, owner ? `@${owner.name}` : ''].join(' ').trim();
}
// Get the name of the personal owner of a workspace, if it is set
// and distinct from the current user. If the personal owner is not
// set, or is the same as the current user, the empty string is
// returned. The personal owner will only be set for workspaces in
// the "docs" pseudo-organization, which is assembled from all the
// personal organizations the current user has access to.
export function ownerName(app: AppModel, ws: Workspace): string {
const {owner, self} = getWorkspaceInfo(app, ws);
return self ? '' : (owner ? owner.name : '');
}
// Information needed for showing the title of a workspace.
export interface WorkspaceInfo {
name: string; // user-specified workspace name (empty if should not be shown)
owner?: FullUser; // personal owner of workspace (if known and should be shown)
self?: boolean; // set if owner is current user
isDefault?: boolean; // set if workspace is current user's 'Home' workspace
}
// Get information needed for showing the title of a workspace.
export function getWorkspaceInfo(app: AppModel, ws: Workspace): WorkspaceInfo {
const user = app.currentUser;
const {name, owner} = ws;
const isHome = name === 'Home';
if (!user || !owner) { return {owner, name}; }
const self = user.id === owner.id;
const isDefault = self && isHome;
if (ws.isSupportWorkspace) {
// Keep workspace name for support workspaces; drop owner name.
return {name, self, isDefault};
}
if (isHome && !isDefault) {
// "Home" workspaces of other users have their names omitted, but we retain
// the name "Home" for the current user's "Home" workspace.
return {name: '', owner, self, isDefault}; // omit name in this case
}
if (self) {
return {name, self, isDefault};
}
return {name, owner, self, isDefault};
}

View File

@@ -0,0 +1,14 @@
import {ACLPrincipalRec, DocModel, IRowModel, refRecord} from 'app/client/models/DocModel';
import * as ko from 'knockout';
// Table for containment relationships between Principals, e.g. user contains multiple
// instances, group contains multiple users, and groups may contain other groups.
export interface ACLMembershipRec extends IRowModel<"_grist_ACLMemberships"> {
parentRec: ko.Computed<ACLPrincipalRec>;
childRec: ko.Computed<ACLPrincipalRec>;
}
export function createACLMembershipRec(this: ACLMembershipRec, docModel: DocModel): void {
this.parentRec = refRecord(docModel.aclPrincipals, this.parent);
this.childRec = refRecord(docModel.aclPrincipals, this.child);
}

View File

@@ -0,0 +1,29 @@
import {KoArray} from 'app/client/lib/koArray';
import {ACLMembershipRec, DocModel, IRowModel, recordSet} from 'app/client/models/DocModel';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import * as ko from 'knockout';
// A principals used by ACL rules, including users, groups, and instances.
export interface ACLPrincipalRec extends IRowModel<"_grist_ACLPrincipals"> {
// Declare a more specific type for 'type' than what's set automatically from schema.ts.
type: KoSaveableObservable<'user'|'instance'|'group'>;
// KoArray of ACLMembership row models which contain this principal as a child.
parentMemberships: ko.Computed<KoArray<ACLMembershipRec>>;
// Gives an array of ACLPrincipal parents to this row model.
parents: ko.Computed<ACLPrincipalRec[]>;
// KoArray of ACLMembership row models which contain this principal as a parent.
childMemberships: ko.Computed<KoArray<ACLMembershipRec>>;
// Gives an array of ACLPrincipal children of this row model.
children: ko.Computed<ACLPrincipalRec[]>;
}
export function createACLPrincipalRec(this: ACLPrincipalRec, docModel: DocModel): void {
this.parentMemberships = recordSet(this, docModel.aclMemberships, 'child');
this.childMemberships = recordSet(this, docModel.aclMemberships, 'parent');
this.parents = ko.pureComputed(() => this.parentMemberships().all().map(m => m.parentRec()));
this.children = ko.pureComputed(() => this.childMemberships().all().map(m => m.childRec()));
}

View File

@@ -0,0 +1,7 @@
import {DocModel, IRowModel} from 'app/client/models/DocModel';
export type ACLResourceRec = IRowModel<"_grist_ACLResources">;
export function createACLResourceRec(this: ACLResourceRec, docModel: DocModel): void {
// no extra fields
}

View File

@@ -0,0 +1,92 @@
import {KoArray} from 'app/client/lib/koArray';
import {DocModel, IRowModel, recordSet, refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
import * as gristTypes from 'app/common/gristTypes';
import {removePrefix} from 'app/common/gutil';
import * as ko from 'knockout';
// Represents a column in a user-defined table.
export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
table: ko.Computed<TableRec>;
widgetOptionsJson: ObjObservable<any>;
viewFields: ko.Computed<KoArray<ViewFieldRec>>;
summarySource: ko.Computed<ColumnRec>;
// Is an empty column (undecided if formula or data); denoted by an empty formula.
isEmpty: ko.Computed<boolean>;
// Is a real formula column (not an empty column; i.e. contains a non-empty formula).
isRealFormula: ko.Computed<boolean>;
// Used for transforming a column.
// Reference to the original column for a transform column, or to itself for a non-transforming column.
origColRef: ko.Observable<number>;
origCol: ko.Computed<ColumnRec>;
// Indicates whether a column is transforming. Manually set, but should be true in both the original
// column being transformed and that column's transform column.
isTransforming: ko.Observable<boolean>;
// Convenience observable to obtain and set the type with no suffix
pureType: ko.Computed<string>;
// The column's display column
_displayColModel: ko.Computed<ColumnRec>;
disableModify: ko.Computed<boolean>;
disableEditData: ko.Computed<boolean>;
isHiddenCol: ko.Computed<boolean>;
// Returns the rowModel for the referenced table, or null, if is not a reference column.
refTable: ko.Computed<TableRec|null>;
// Helper which adds/removes/updates column's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined;
}
export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
this.table = refRecord(docModel.tables, this.parentId);
this.widgetOptionsJson = jsonObservable(this.widgetOptions);
this.viewFields = recordSet(this, docModel.viewFields, 'colRef');
this.summarySource = refRecord(docModel.columns, this.summarySourceCol);
// Is this an empty column (undecided if formula or data); denoted by an empty formula.
this.isEmpty = ko.pureComputed(() => this.isFormula() && this.formula() === '');
// Is this a real formula column (not an empty column; i.e. contains a non-empty formula).
this.isRealFormula = ko.pureComputed(() => this.isFormula() && this.formula() !== '');
// Used for transforming a column.
// Reference to the original column for a transform column, or to itself for a non-transforming column.
this.origColRef = ko.observable(this.getRowId());
this.origCol = refRecord(docModel.columns, this.origColRef);
// Indicates whether a column is transforming. Manually set, but should be true in both the original
// column being transformed and that column's transform column.
this.isTransforming = ko.observable(false);
// Convenience observable to obtain and set the type with no suffix
this.pureType = ko.pureComputed(() => gristTypes.extractTypeFromColType(this.type()));
// The column's display column
this._displayColModel = refRecord(docModel.columns, this.displayCol);
// Helper which adds/removes/updates this column's displayCol to match the formula.
this.saveDisplayFormula = function(formula) {
if (formula !== (this._displayColModel().formula() || '')) {
return docModel.docData.sendAction(["SetDisplayFormula", this.table().tableId(),
null, this.getRowId(), formula]);
}
};
this.disableModify = ko.pureComputed(() => Boolean(this.summarySourceCol()));
this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));
this.isHiddenCol = ko.pureComputed(() => this.colId().startsWith('gristHelper_') ||
this.colId() === 'manualSort');
// Returns the rowModel for the referenced table, or null, if this is not a reference column.
this.refTable = ko.pureComputed(() => {
const refTableId = removePrefix(this.type() || "", 'Ref:');
return refTableId ? docModel.allTables.all().find(t => t.tableId() === refTableId) || null : null;
});
}

View File

@@ -0,0 +1,19 @@
import {DocModel, IRowModel} from 'app/client/models/DocModel';
import * as ko from 'knockout';
// The document-wide metadata. It's all contained in a single record with id=1.
export interface DocInfoRec extends IRowModel<"_grist_DocInfo"> {
defaultViewId: ko.Computed<number>;
newDefaultViewId: ko.Computed<number>;
}
export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void {
this.defaultViewId = this.autoDispose(ko.pureComputed(() => {
const tab = docModel.allTabs.at(0);
return tab ? tab.viewRef() : 0;
}));
this.newDefaultViewId = this.autoDispose(ko.pureComputed(() => {
const page = docModel.allDocPages.at(0);
return page ? page.viewRef() : 0;
}));
}

View File

@@ -0,0 +1,11 @@
import {DocModel, IRowModel, refRecord, ViewRec} from 'app/client/models/DocModel';
import * as ko from 'knockout';
// Represents a page entry in the tree of pages.
export interface PageRec extends IRowModel<"_grist_Pages"> {
view: ko.Computed<ViewRec>;
}
export function createPageRec(this: PageRec, docModel: DocModel): void {
this.view = refRecord(docModel.views, this.viewRef);
}

View File

@@ -0,0 +1,8 @@
import {DocModel, IRowModel} from 'app/client/models/DocModel';
// Record of input code and output text and error info for REPL.
export type REPLRec = IRowModel<"_grist_REPL_Hist">
export function createREPLRec(this: REPLRec, docModel: DocModel): void {
// no extra fields
}

View File

@@ -0,0 +1,11 @@
import {DocModel, IRowModel, refRecord, ViewRec} from 'app/client/models/DocModel';
import * as ko from 'knockout';
// Represents a page entry in the tree of pages.
export interface TabBarRec extends IRowModel<"_grist_TabBar"> {
view: ko.Computed<ViewRec>;
}
export function createTabBarRec(this: TabBarRec, docModel: DocModel): void {
this.view = refRecord(docModel.views, this.viewRef);
}

View File

@@ -0,0 +1,77 @@
import {KoArray} from 'app/client/lib/koArray';
import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel';
import {ColumnRec, TableViewRec, ValidationRec, ViewRec} from 'app/client/models/DocModel';
import {MANUALSORT} from 'app/common/gristTypes';
import * as ko from 'knockout';
import toUpper = require('lodash/toUpper');
import * as randomcolor from 'randomcolor';
// Represents a user-defined table.
export interface TableRec extends IRowModel<"_grist_Tables"> {
columns: ko.Computed<KoArray<ColumnRec>>;
validations: ko.Computed<KoArray<ValidationRec>>;
primaryView: ko.Computed<ViewRec>;
tableViewItems: ko.Computed<KoArray<TableViewRec>>;
summarySource: ko.Computed<TableRec>;
// A Set object of colRefs for all summarySourceCols of table.
summarySourceColRefs: ko.Computed<Set<number>>;
// tableId for normal tables, or tableId of the source table for summary tables.
primaryTableId: ko.Computed<string>;
// The list of grouped by columns.
groupByColumns: ko.Computed<ColumnRec[]>;
// The user-friendly name of the table, which is the same as tableId for non-summary tables,
// and is 'tableId[groupByCols...]' for summary tables.
tableTitle: ko.Computed<string>;
tableColor: string;
disableAddRemoveRows: ko.Computed<boolean>;
supportsManualSort: ko.Computed<boolean>;
}
export function createTableRec(this: TableRec, docModel: DocModel): void {
this.columns = recordSet(this, docModel.columns, 'parentId', {sortBy: 'parentPos'});
this.validations = recordSet(this, docModel.validations, 'tableRef');
this.primaryView = refRecord(docModel.views, this.primaryViewId);
this.tableViewItems = recordSet(this, docModel.tableViews, 'tableRef', {sortBy: 'viewRef'});
this.summarySource = refRecord(docModel.tables, this.summarySourceTable);
// A Set object of colRefs for all summarySourceCols of this table.
this.summarySourceColRefs = this.autoDispose(ko.pureComputed(() => new Set(
this.columns().all().map(c => c.summarySourceCol()).filter(colRef => colRef))));
// tableId for normal tables, or tableId of the source table for summary tables.
this.primaryTableId = ko.pureComputed(() =>
this.summarySourceTable() ? this.summarySource().tableId() : this.tableId());
this.groupByColumns = ko.pureComputed(() => this.columns().all().filter(c => c.summarySourceCol()));
const groupByDesc = ko.pureComputed(() => {
const groupBy = this.groupByColumns();
return groupBy.length ? 'by ' + groupBy.map(c => c.label()).join(", ") : "Totals";
});
// The user-friendly name of the table, which is the same as tableId for non-summary tables,
// and is 'tableId[groupByCols...]' for summary tables.
this.tableTitle = ko.pureComputed(() => {
if (this.summarySourceTable()) {
return toUpper(this.summarySource().tableId()) + " [" + groupByDesc() + "]";
}
return toUpper(this.tableId());
});
// TODO: We should save this value and let users change it.
this.tableColor = randomcolor({
luminosity: 'light',
seed: typeof this.id() === 'number' ? 5 * this.id() : this.id()
});
this.disableAddRemoveRows = ko.pureComputed(() => Boolean(this.summarySourceTable()));
this.supportsManualSort = ko.pureComputed(() => this.columns().all().some(c => c.colId() === MANUALSORT));
}

View File

@@ -0,0 +1,13 @@
import {DocModel, IRowModel, refRecord, TableRec, ViewRec} from 'app/client/models/DocModel';
import * as ko from 'knockout';
// Used in old-style list of views grouped by table.
export interface TableViewRec extends IRowModel<"_grist_TableViews"> {
table: ko.Computed<TableRec>;
view: ko.Computed<ViewRec>;
}
export function createTableViewRec(this: TableViewRec, docModel: DocModel): void {
this.table = refRecord(docModel.tables, this.tableRef);
this.view = refRecord(docModel.views, this.viewRef);
}

View File

@@ -0,0 +1,8 @@
import {DocModel, IRowModel} from 'app/client/models/DocModel';
// Represents a validation rule.
export type ValidationRec = IRowModel<"_grist_Validations">
export function createValidationRec(this: ValidationRec, docModel: DocModel): void {
// no extra fields
}

View File

@@ -0,0 +1,204 @@
import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil';
import * as UserType from 'app/client/widgets/UserType';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
import {Computed, fromKo} from 'grainjs';
import * as ko from 'knockout';
// Represents a page entry in the tree of pages.
export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
viewSection: ko.Computed<ViewSectionRec>;
widthDef: modelUtil.KoSaveableObservable<number>;
widthPx: ko.Computed<string>;
column: ko.Computed<ColumnRec>;
origCol: ko.Computed<ColumnRec>;
colId: ko.Computed<string>;
label: ko.Computed<string>;
// displayLabel displays label by default but switches to the more helpful colId whenever a
// formula field in the view is being edited.
displayLabel: modelUtil.KoSaveableObservable<string>;
// The field knows when we are editing a formula, so that all rows can reflect that.
editingFormula: ko.Computed<boolean>;
// CSS class to add to formula cells, incl. to show that we are editing field's formula.
formulaCssClass: ko.Computed<string|null>;
// The fields's display column
_displayColModel: ko.Computed<ColumnRec>;
// Whether field uses column's widgetOptions (true) or its own (false).
// During transform, use the transform column's options (which should be initialized to match
// field or column when the transform starts TODO).
useColOptions: ko.Computed<boolean>;
// Helper that returns the RowModel for either field or its column, depending on
// useColOptions. Field and Column have a few identical fields:
// .widgetOptions() // JSON string of options
// .saveDisplayFormula() // Method to save the display formula
// .displayCol() // Reference to an optional associated display column.
_fieldOrColumn: ko.Computed<ColumnRec|ViewFieldRec>;
// Display col ref to use for the field, defaulting to the plain column itself.
displayColRef: ko.Computed<number>;
visibleColRef: modelUtil.KoSaveableObservable<number>;
// The display column to use for the field, or the column itself when no displayCol is set.
displayColModel: ko.Computed<ColumnRec>;
visibleColModel: ko.Computed<ColumnRec>;
// The widgetOptions to read and write: either the column's or the field's own.
_widgetOptionsStr: modelUtil.KoSaveableObservable<string>;
// Observable for the object with the current options, either for the field or for the column,
// which takes into account the default options for column's type.
widgetOptionsJson: modelUtil.SaveableObjObservable<any>;
// Observable for the parsed filter object saved to the field.
activeFilter: modelUtil.CustomComputed<string>;
// Computed boolean that's true when there's a saved filter
isFiltered: Computed<boolean>;
disableModify: ko.Computed<boolean>;
disableEditData: ko.Computed<boolean>;
textColor: modelUtil.KoSaveableObservable<string>;
fillColor: modelUtil.KoSaveableObservable<string>;
// Helper which adds/removes/updates field's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined;
// Helper for Reference columns, which returns a formatter according to the visibleCol
// associated with field. Subscribes to observables if used within a computed.
createVisibleColFormatter(): BaseFormatter;
}
export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void {
this.viewSection = refRecord(docModel.viewSections, this.parentId);
this.widthDef = modelUtil.fieldWithDefault(this.width, () => this.viewSection().defaultWidth());
this.widthPx = ko.pureComputed(() => this.widthDef() + 'px');
this.column = refRecord(docModel.columns, this.colRef);
this.origCol = ko.pureComputed(() => this.column().origCol());
this.colId = ko.pureComputed(() => this.column().colId());
this.label = ko.pureComputed(() => this.column().label());
// displayLabel displays label by default but switches to the more helpful colId whenever a
// formula field in the view is being edited.
this.displayLabel = modelUtil.savingComputed({
read: () => docModel.editingFormula() ? '$' + this.origCol().colId() : this.origCol().label(),
write: (setter, val) => setter(this.column().label, val)
});
// The field knows when we are editing a formula, so that all rows can reflect that.
const _editingFormula = ko.observable(false);
this.editingFormula = ko.pureComputed({
read: () => _editingFormula(),
write: val => {
// Whenever any view field changes its editingFormula status, let the docModel know.
docModel.editingFormula(val);
_editingFormula(val);
}
});
// CSS class to add to formula cells, incl. to show that we are editing this field's formula.
this.formulaCssClass = ko.pureComputed<string|null>(() => {
const col = this.column();
return this.column().isTransforming() ? "transform_field" :
(this.editingFormula() ? "formula_field_edit" :
(col.isFormula() && col.formula() !== "" ? "formula_field" : null));
});
// The fields's display column
this._displayColModel = refRecord(docModel.columns, this.displayCol);
// Helper which adds/removes/updates this field's displayCol to match the formula.
this.saveDisplayFormula = function(formula) {
if (formula !== (this._displayColModel().formula() || '')) {
return docModel.docData.sendAction(["SetDisplayFormula", this.column().table().tableId(),
this.getRowId(), null, formula]);
}
};
// Whether this field uses column's widgetOptions (true) or its own (false).
// During transform, use the transform column's options (which should be initialized to match
// field or column when the transform starts TODO).
this.useColOptions = ko.pureComputed(() => !this.widgetOptions() || this.column().isTransforming());
// Helper that returns the RowModel for either this field or its column, depending on
// useColOptions. Field and Column have a few identical fields:
// .widgetOptions() // JSON string of options
// .saveDisplayFormula() // Method to save the display formula
// .displayCol() // Reference to an optional associated display column.
this._fieldOrColumn = ko.pureComputed(() => this.useColOptions() ? this.column() : this);
// Display col ref to use for the field, defaulting to the plain column itself.
this.displayColRef = ko.pureComputed(() => this._fieldOrColumn().displayCol() || this.colRef());
this.visibleColRef = modelUtil.addSaveInterface(ko.pureComputed({
read: () => this._fieldOrColumn().visibleCol(),
write: (colRef) => this._fieldOrColumn().visibleCol(colRef),
}),
colRef => docModel.docData.bundleActions(null, async () => {
const col = docModel.columns.getRowModel(colRef);
await Promise.all([
this._fieldOrColumn().visibleCol.saveOnly(colRef),
this._fieldOrColumn().saveDisplayFormula(colRef ? `$${this.colId()}.${col.colId()}` : '')
]);
})
);
// The display column to use for the field, or the column itself when no displayCol is set.
this.displayColModel = refRecord(docModel.columns, this.displayColRef);
this.visibleColModel = refRecord(docModel.columns, this.visibleColRef);
// Helper for Reference columns, which returns a formatter according to the visibleCol
// associated with this field. If no visible column available, return formatting for the field itself.
// Subscribes to observables if used within a computed.
// TODO: It would be better to replace this with a pureComputed whose value is a formatter.
this.createVisibleColFormatter = function() {
const vcol = this.visibleColModel();
return (vcol.getRowId() !== 0) ?
createFormatter(vcol.type(), vcol.widgetOptionsJson()) :
createFormatter(this.column().type(), this.widgetOptionsJson());
};
// The widgetOptions to read and write: either the column's or the field's own.
this._widgetOptionsStr = modelUtil.savingComputed({
read: () => this._fieldOrColumn().widgetOptions(),
write: (setter, val) => setter(this._fieldOrColumn().widgetOptions, val)
});
// Observable for the object with the current options, either for the field or for the column,
// which takes into account the default options for this column's type.
this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr,
(opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType()));
// Observable for the active filter that's initialized from the value saved to the server.
this.activeFilter = modelUtil.customComputed({
read: () => { const f = this.filter(); return f === 'null' ? '' : f; }, // To handle old empty filters
save: (val) => this.filter.saveOnly(val),
});
this.isFiltered = Computed.create(this, fromKo(this.activeFilter), (_use, f) => f !== '');
this.disableModify = ko.pureComputed(() => this.column().disableModify());
this.disableEditData = ko.pureComputed(() => this.column().disableEditData());
this.textColor = modelUtil.fieldWithDefault(
this.widgetOptionsJson.prop('textColor') as modelUtil.KoSaveableObservable<string>, "#000000");
const fillColorProp = this.widgetOptionsJson.prop('fillColor') as modelUtil.KoSaveableObservable<string>;
// Store empty string in place of the default white color, so that we can keep it transparent in
// GridView, to avoid interfering with zebra stripes.
this.fillColor = modelUtil.savingComputed({
read: () => fillColorProp(),
write: (setter, val) => setter(fillColorProp, val === '#ffffff' ? '' : val),
});
}

View File

@@ -0,0 +1,53 @@
import {KoArray} from 'app/client/lib/koArray';
import * as koUtil from 'app/client/lib/koUtil';
import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel';
import {TabBarRec, TableViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil';
import * as ko from 'knockout';
// Represents a view (now also referred to as a "page") containing one or more view sections.
export interface ViewRec extends IRowModel<"_grist_Views"> {
viewSections: ko.Computed<KoArray<ViewSectionRec>>;
tableViewItems: ko.Computed<KoArray<TableViewRec>>;
tabBarItem: ko.Computed<KoArray<TabBarRec>>;
layoutSpecObj: modelUtil.ObjObservable<any>;
// An observable for the ref of the section last selected by the user.
activeSectionId: ko.Computed<number>;
activeSection: ko.Computed<ViewSectionRec>;
// If the active section is removed, set the next active section to be the default.
_isActiveSectionGone: ko.Computed<boolean>;
isLinking: ko.Observable<boolean>;
}
export function createViewRec(this: ViewRec, docModel: DocModel): void {
this.viewSections = recordSet(this, docModel.viewSections, 'parentId');
this.tableViewItems = recordSet(this, docModel.tableViews, 'viewRef', {sortBy: 'tableRef'});
this.tabBarItem = recordSet(this, docModel.tabBar, 'viewRef');
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);
// An observable for the ref of the section last selected by the user.
this.activeSectionId = koUtil.observableWithDefault(ko.observable(), () => {
// The default function which is used when the conditional case is true.
// Read may occur for recently disposed sections, must check condition first.
return !this.isDisposed() &&
this.viewSections().all().length > 0 ? this.viewSections().at(0)!.getRowId() : 0;
});
this.activeSection = refRecord(docModel.viewSections, this.activeSectionId);
// If the active section is removed, set the next active section to be the default.
this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted()));
this.autoDispose(this._isActiveSectionGone.subscribe(gone => {
if (gone) {
this.activeSectionId(0);
}
}));
this.isLinking = ko.observable(false);
}

View File

@@ -0,0 +1,275 @@
import * as BaseView from 'app/client/components/BaseView';
import {CursorPos} from 'app/client/components/Cursor';
import {KoArray} from 'app/client/lib/koArray';
import {ColumnRec, TableRec, ViewFieldRec, ViewRec} from 'app/client/models/DocModel';
import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil';
import {RowId} from 'app/client/models/rowset';
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
import {Computed} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
// a grid section and a chart section).
export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
viewFields: ko.Computed<KoArray<ViewFieldRec>>;
optionsObj: modelUtil.SaveableObjObservable<any>;
customDef: CustomViewSectionDef;
themeDef: modelUtil.KoSaveableObservable<string>;
chartTypeDef: modelUtil.KoSaveableObservable<string>;
view: ko.Computed<ViewRec>;
table: ko.Computed<TableRec>;
tableTitle: ko.Computed<string>;
titleDef: modelUtil.KoSaveableObservable<string>;
borderWidthPx: ko.Computed<string>;
layoutSpecObj: modelUtil.ObjObservable<any>;
// Helper metadata item which indicates whether any of the section's fields have unsaved
// changes to their filters. (True indicates unsaved changes)
filterSpecChanged: Computed<boolean>;
// Array of fields with an active filter
filteredFields: Computed<ViewFieldRec[]>;
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
activeSortJson: modelUtil.CustomComputed<string>;
// is an array (parsed from JSON) of colRefs (i.e. rowIds into the columns table), with a
// twist: a rowId may be positive or negative, for ascending or descending respectively.
activeSortSpec: modelUtil.ObjObservable<number[]>;
// Modified sort spec to take into account any active display columns.
activeDisplaySortSpec: ko.Computed<number[]>;
// Evaluates to an array of column models, which are not referenced by anything in viewFields.
hiddenColumns: ko.Computed<ColumnRec[]>;
hasFocus: ko.Computed<boolean>;
activeLinkSrcSectionRef: modelUtil.CustomComputed<number>;
activeLinkSrcColRef: modelUtil.CustomComputed<number>;
activeLinkTargetColRef: modelUtil.CustomComputed<number>;
// Whether current linking state is as saved. It may be different during editing.
isActiveLinkSaved: ko.Computed<boolean>;
// Section-linking affects table if linkSrcSection is set. The controller value of the
// link is the value of srcCol at activeRowId of linkSrcSection, or activeRowId itself when
// srcCol is unset. If targetCol is set, we filter for all rows whose targetCol is equal to
// the controller value. Otherwise, the controller value determines the rowId of the cursor.
linkSrcSection: ko.Computed<ViewSectionRec>;
linkSrcCol: ko.Computed<ColumnRec>;
linkTargetCol: ko.Computed<ColumnRec>;
activeRowId: ko.Observable<RowId|null>; // May be null when there are no rows.
// If the view instance for section is instantiated, it will be accessible here.
viewInstance: ko.Observable<BaseView|null>;
// Describes the most recent cursor position in the section. Only rowId and fieldIndex are used.
lastCursorPos: CursorPos;
// Describes the most recent scroll position.
lastScrollPos: {
rowIndex: number; // Used for scrolly sections. Indicates the index of the first visible row.
offset: number; // Pixel distance past the top of row indicated by rowIndex.
scrollLeft: number; // Used for grid sections. Indicates the scrollLeft value of the scroll pane.
};
disableAddRemoveRows: ko.Computed<boolean>;
isSorted: ko.Computed<boolean>;
disableDragRows: ko.Computed<boolean>;
// Save all filters of fields in the section.
saveFilters(): Promise<void>;
// Revert all filters of fields in the section.
revertFilters(): void;
// Clear and save all filters of fields in the section.
clearFilters(): void;
}
export interface CustomViewSectionDef {
/**
* The mode.
*/
mode: ko.Observable<"url"|"plugin">;
/**
* The url.
*/
url: ko.Observable<string>;
/**
* Access granted to url.
*/
access: ko.Observable<string>;
/**
* The plugin id.
*/
pluginId: ko.Observable<string>;
/**
* The section id.
*/
sectionId: ko.Observable<string>;
}
export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void {
this.viewFields = recordSet(this, docModel.viewFields, 'parentId', {sortBy: 'parentPos'});
const defaultOptions = {
verticalGridlines: true,
horizontalGridlines: true,
zebraStripes: false,
customView: '',
};
this.optionsObj = modelUtil.jsonObservable(this.options,
(obj: any) => defaults(obj || {}, defaultOptions));
const customViewDefaults = {
mode: 'url',
url: '',
access: '',
pluginId: '',
sectionId: ''
};
const customDefObj = modelUtil.jsonObservable(this.optionsObj.prop('customView'),
(obj: any) => defaults(obj || {}, customViewDefaults));
this.customDef = {
mode: customDefObj.prop('mode'),
url: customDefObj.prop('url'),
access: customDefObj.prop('access'),
pluginId: customDefObj.prop('pluginId'),
sectionId: customDefObj.prop('sectionId')
};
this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form');
this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, 'bar');
this.view = refRecord(docModel.views, this.parentId);
this.table = refRecord(docModel.tables, this.tableRef);
this.tableTitle = this.autoDispose(ko.pureComputed(() => this.table().tableTitle()));
this.titleDef = modelUtil.fieldWithDefault(
this.title,
() => this.table().tableTitle() + (
(this.parentKey() === 'record') ? '' : ` ${getWidgetTypes(this.parentKey.peek() as any).label}`
)
);
this.borderWidthPx = ko.pureComputed(function() { return this.borderWidth() + 'px'; }, this);
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);
// Helper metadata item which indicates whether any of the section's fields have unsaved
// changes to their filters. (True indicates unsaved changes)
this.filterSpecChanged = Computed.create(this, use =>
use(use(this.viewFields).getObservable()).some(field => !use(field.activeFilter.isSaved)));
this.filteredFields = Computed.create(this, use =>
use(use(this.viewFields).getObservable()).filter(field => use(field.isFiltered)));
// Save all filters of fields in the section.
this.saveFilters = () => {
return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,
async () => { await Promise.all(this.viewFields().all().map(field => field.activeFilter.save())); }
);
};
// Revert all filters of fields in the section.
this.revertFilters = () => {
this.viewFields().all().forEach(field => { field.activeFilter.revert(); });
};
// Reset all filters of fields in the section to their default (i.e. unset) values.
this.clearFilters = () => this.viewFields().all().forEach(field => field.activeFilter(''));
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
this.activeSortJson = modelUtil.customValue(this.sortColRefs);
// This is an array (parsed from JSON) of colRefs (i.e. rowIds into the columns table), with a
// twist: a rowId may be positive or negative, for ascending or descending respectively.
// TODO: This method of ignoring columns which are deleted is inefficient and may cause conflicts
// with sharing.
this.activeSortSpec = modelUtil.jsonObservable(this.activeSortJson, (obj: any) => {
return (obj || []).filter((sortRef: number) => {
const colModel = docModel.columns.getRowModel(Math.abs(sortRef));
return !colModel._isDeleted() && colModel.getRowId();
});
});
// Modified sort spec to take into account any active display columns.
this.activeDisplaySortSpec = this.autoDispose(ko.computed(() => {
return this.activeSortSpec().map(directionalColRef => {
const colRef = Math.abs(directionalColRef);
const field = this.viewFields().all().find(f => f.column().origColRef() === colRef);
const effectiveColRef = field ? field.displayColRef() : colRef;
return directionalColRef > 0 ? effectiveColRef : -effectiveColRef;
});
}));
// Evaluates to an array of column models, which are not referenced by anything in viewFields.
this.hiddenColumns = this.autoDispose(ko.pureComputed(() => {
const included = new Set(this.viewFields().all().map((f) => f.column().origColRef()));
return this.table().columns().all().filter(function(col) {
return !included.has(col.getRowId()) && !col.isHiddenCol();
});
}));
this.hasFocus = ko.pureComputed({
// Read may occur for recently disposed sections, must check condition first.
read: () => !this.isDisposed() && this.view().activeSectionId() === this.id() && !this.view().isLinking(),
write: (val) => { if (val) { this.view().activeSectionId(this.id()); } }
});
this.activeLinkSrcSectionRef = modelUtil.customValue(this.linkSrcSectionRef);
this.activeLinkSrcColRef = modelUtil.customValue(this.linkSrcColRef);
this.activeLinkTargetColRef = modelUtil.customValue(this.linkTargetColRef);
// Whether current linking state is as saved. It may be different during editing.
this.isActiveLinkSaved = this.autoDispose(ko.pureComputed(() =>
this.activeLinkSrcSectionRef.isSaved() &&
this.activeLinkSrcColRef.isSaved() &&
this.activeLinkTargetColRef.isSaved()));
// Section-linking affects this table if linkSrcSection is set. The controller value of the
// link is the value of srcCol at activeRowId of linkSrcSection, or activeRowId itself when
// srcCol is unset. If targetCol is set, we filter for all rows whose targetCol is equal to
// the controller value. Otherwise, the controller value determines the rowId of the cursor.
this.linkSrcSection = refRecord(docModel.viewSections, this.activeLinkSrcSectionRef);
this.linkSrcCol = refRecord(docModel.columns, this.activeLinkSrcColRef);
this.linkTargetCol = refRecord(docModel.columns, this.activeLinkTargetColRef);
this.activeRowId = ko.observable();
// If the view instance for this section is instantiated, it will be accessible here.
this.viewInstance = ko.observable(null);
// Describes the most recent cursor position in the section.
this.lastCursorPos = {
rowId: 0,
fieldIndex: 0
};
// Describes the most recent scroll position.
this.lastScrollPos = {
rowIndex: 0, // Used for scrolly sections. Indicates the index of the first visible row.
offset: 0, // Pixel distance past the top of row indicated by rowIndex.
scrollLeft: 0 // Used for grid sections. Indicates the scrollLeft value of the scroll pane.
};
this.disableAddRemoveRows = ko.pureComputed(() => this.table().disableAddRemoveRows());
this.isSorted = ko.pureComputed(() => this.activeSortSpec().length > 0);
this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());
}

149
app/client/models/errors.ts Normal file
View File

@@ -0,0 +1,149 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import * as log from 'app/client/lib/log';
import {INotifyOptions, Notifier} from 'app/client/models/NotifyModel';
import {ApiErrorDetails} from 'app/common/ApiError';
import {fetchFromHome, pageHasHome} from 'app/common/urlUtils';
import isError = require('lodash/isError');
import pick = require('lodash/pick');
const G = getBrowserGlobals('document', 'window');
let _notifier: Notifier;
export class UserError extends Error {
public name: string = "UserError";
public key?: string;
constructor(message: string, options: {key?: string} = {}) {
super(message);
this.key = options.key;
}
}
/**
* This error causes Notifer to show the message with an upgrade link.
*/
export class NeedUpgradeError extends Error {
public name: string = 'NeedUpgradeError';
constructor(message: string = 'This feature is not available in your plan') {
super(message);
}
}
/**
* Set the global Notifier instance used by subsequent reportError calls.
*/
export function setErrorNotifier(notifier: Notifier) {
_notifier = notifier;
}
// Returns application errors collected by NotifyModel. Used in tests.
export function getAppErrors(): string[] {
return _notifier.getFullAppErrors().map((e) => e.error.message);
}
/**
* Report an error to the user using the global Notifier instance. If the argument is a UserError
* or an error with a status in the 400 range, it indicates a user error. Otherwise, it's an
* application error, which the user can report to us as a bug.
*/
export function reportError(err: Error|string): void {
log.error(`ERROR:`, err);
_logError(err);
if (_notifier && !_notifier.isDisposed()) {
if (!isError(err)) {
err = new Error(String(err));
}
const details: ApiErrorDetails|undefined = (err as any).details;
const code: unknown = (err as any).code;
const status: unknown = (err as any).status;
const message = (details && details.userError) || err.message;
if (details && details.limit) {
// This is a notification about reaching a plan limit. Key prevents showing multiple
// notifications for the same type of limit.
const options: Partial<INotifyOptions> = {
title: "Reached plan limit",
key: `limit:${details.limit.quantity || message}`,
actions: ['upgrade'],
};
if (details.tips && details.tips.some(tip => tip.action === 'add-members')) {
// When adding members would fix a problem, give more specific advice.
options.title = "Add users as team members first";
options.actions = [];
}
_notifier.createUserError(message, options);
} else if (err.name === 'UserError' || (typeof status === 'number' && status >= 400 && status < 500)) {
// This is explicitly a user error, or one in the "Client Error" range, so treat it as user
// error rather than a bug. Using message as the key causes same-message notifications to
// replace previous ones rather than accumulate.
const options: Partial<INotifyOptions> = {key: (err as UserError).key || message};
if (details && details.tips && details.tips.some(tip => tip.action === 'ask-for-help')) {
options.actions = ['ask-for-help'];
}
_notifier.createUserError(message, options);
} else if (err.name === 'NeedUpgradeError') {
_notifier.createUserError(err.message, {actions: ['upgrade'], key: 'NEED_UPGRADE'});
} else if (code === 'AUTH_NO_EDIT') {
_notifier.createUserError(message, {key: code});
} else {
// If we don't recognize it, consider it an application error (bug) that the user should be
// able to report.
_notifier.createAppError(err);
}
}
}
/**
* Set up error handlers, to report uncaught errors and rejections. These are logged to the
* console and displayed as notifications, when the notifications UI is set up.
*
* koUtil, if passed, will enable reporting errors from the evaluation of knockout computeds. It
* is passed-in as an argument to avoid creating a dependency when knockout isn't used otherwise.
*/
export function setUpErrorHandling(doReportError = reportError, koUtil?: any) {
if (koUtil) {
koUtil.setComputedErrorHandler((err: any) => doReportError(err));
}
// Report also uncaught JS errors and unhandled Promise rejections.
G.window.onerror = ((ev: any, url: any, lineNo: any, colNo: any, err: any) =>
doReportError(err || ev));
G.window.addEventListener('unhandledrejection', (ev: any) => {
const reason = ev.reason || (ev.detail && ev.detail.reason);
doReportError(reason || ev);
});
// Expose globally a function to report a notification. This is for compatibility with old UI;
// in new UI, it renders messages as user errors. New code should use `reportError()` instead.
G.window.gristNotify = (message: string) => doReportError(new UserError(message));
// Expose the function used in tests to get a list of errors in the notifier.
G.window.getAppErrors = getAppErrors;
}
/**
* Send information about a problem to the backend. This is crude; there is some
* over-logging (regular errors such as access rights or account limits) and
* under-logging (javascript errors during startup might never get reported).
*/
function _logError(error: Error|string) {
if (!pageHasHome()) { return; }
fetchFromHome('/api/log', {
method: 'POST',
body: JSON.stringify({
// Errors don't stringify, so pick out properties explicitly for errors.
event: (error instanceof Error) ? pick(error, Object.getOwnPropertyNames(error)) : error,
page: G.window.location.href,
browser: pick(G.window.navigator, ['language', 'platform', 'userAgent'])
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
}).catch(e => {
// There ... isn't much we can do about this.
// tslint:disable-next-line:no-console
console.warn('Failed to log event', event);
});
}

View File

@@ -0,0 +1,62 @@
/**
* When app.html is fetched, the results for the API calls for getDoc() and getWorker() are
* embedded into the page using window.gristConfig object. When making these calls on the client,
* we check gristConfig to see if we can use these cached values.
*
* Usage is simply:
* getDoc(api, docId)
* getWorker(api, assignmentId)
*
* The cached value is used once only (and reset in gristConfig) and only if marked with a recent
* timestamp. This optimizes the case of loading the page. On subsequent use, these calls will
* translate to the usual api.getDoc(), api.getWorker() calls.
*/
import {urlState} from 'app/client/models/gristUrlState';
import {getWeakestRole} from 'app/common/roles';
import {getGristConfig} from 'app/common/urlUtils';
import {Document, UserAPI} from 'app/common/UserAPI';
// tslint:disable:no-console
const MaxGristConfigAgeMs = 5000;
export async function getDoc(api: UserAPI, docId: string): Promise<Document> {
const value = findAndResetInGristConfig('getDoc', docId);
const result = await (value || api.getDoc(docId));
const mode = urlState().state.get().mode;
if (mode === 'view') {
// This mode will be honored by the websocket; here we make sure the rest of the
// client knows about it too.
result.access = getWeakestRole(result.access, 'viewers');
}
return result;
}
export async function getWorker(api: UserAPI, assignmentId: string): Promise<string> {
const value = findAndResetInGristConfig('getWorker', assignmentId);
return value || api.getWorker(assignmentId);
}
type CallType = "getDoc" | "getWorker";
function findAndResetInGristConfig(method: "getDoc", id: string): Document|null;
function findAndResetInGristConfig(method: "getWorker", id: string): string|null;
function findAndResetInGristConfig(method: CallType, id: string): any {
const gristConfig = getGristConfig();
const methodCache = gristConfig[method];
if (!methodCache || !methodCache[id]) {
console.log(`gristConfigCache ${method}[${id}]: not found`);
return null;
}
// Ignores difference between client and server timestamps, but doing better seems difficult.
const timeSinceServer = Date.now() - gristConfig.timestampMs;
if (timeSinceServer >= MaxGristConfigAgeMs) {
console.log(`gristConfigCache ${method}[${id}]: ${gristConfig.timestampMs} is stale (${timeSinceServer})`);
return null;
}
const value = methodCache[id];
delete methodCache[id]; // To be used only once.
console.log(`gristConfigCache ${method}[${id}]: found and deleted value`, value);
return value;
}

View File

@@ -0,0 +1,320 @@
var _ = require('underscore');
var Promise = require('bluebird');
var assert = require('assert');
var gutil = require('app/common/gutil');
var ko = require('knockout');
var koUtil = require('../lib/koUtil');
/**
* Adds a family of 'save' methods to an observable. It accepts a callback for saving a value
* (presumably to the server), and adds the following methods:
* @method save() Saves the current value of the observable to the server.
* @method saveOnly(obj) Saves the given value, without changing the observable's value.
* @method setAndSave(obj) Sets a new value for the observable and saves it.
* @returns {Observable} Returns the passed-on observable.
*/
function addSaveInterface(observable, saveFunc) {
observable.saveOnly = function(value) {
// Calls saveFunc and notifies subscribers of 'save' events.
return Promise.try(() => saveFunc.call(this, value))
.tap(() => observable.notifySubscribers(value, "save"));
};
observable.save = function() {
return this.saveOnly(this.peek());
};
observable.setAndSave = function(value) {
this(value);
return this.saveOnly(value);
};
return observable;
}
exports.addSaveInterface = addSaveInterface;
/**
* Creates a pureComputed with a read/write/save interface. The argument is an object with two
* properties: `read` is the same as for a computed or a pureComputed. `write` is different: it is
* a callback called as write(setter, value), where `setter(obs, value)` can be used with another
* observable to write or save to it. E.g. if `foo` is an observable:
*
* let bar = savingComputed({
* read: () => foo(),
* write: (setter, val) => setter(foo, val.toUpperCase())
* })
*
* Now `bar()` has the value of foo, calling `bar("hello")` will call `foo("HELLO")`, and
* `bar.saveOnly("hello")` will call `foo.saveOnly("HELLO")`.
*/
function savingComputed(options) {
return addSaveInterface(ko.pureComputed({
read: options.read,
write: val => options.write(_writeSetter, val)
}), val => options.write(_saveSetter, val));
}
exports.savingComputed = savingComputed;
function _writeSetter(obs, val) { return obs(val); }
function _saveSetter(obs, val) { return obs.saveOnly(val); }
/**
* Set and save the observable to the given value if it would change the value of the observable.
* If the observable has no .save() interface, then the saving is skipped. If the save() call
* fails, then the observable gets reset to its previous value.
* @param {Observable} observable: Observable which may support the 'save' interface.
* @param {Object} value: An arbitrary value. If identical to the current value of the observable,
* then the call is a no-op.
* @param {Object} optOrigValue: If given, will use it as the original value of the observable: if
* it matches value, will skip saving; if save fails, will revert to this original.
* @returns {undefined|Promise} If saving, a promise for when save() completes, else undefined.
*/
function setSaveValue(observable, value, optOrigValue) {
let orig = (optOrigValue === undefined) ? observable.peek() : optOrigValue;
if (value !== orig) {
observable(value);
if (observable.save) {
return Promise.try(() => observable.save())
.catch(err => {
console.warn("setSaveValue %s -> %s failed: %s", orig, value, err);
observable(orig);
throw err;
});
}
}
}
exports.setSaveValue = setSaveValue;
/**
* Creates an observable for a field value. It accepts a callback for saving its value to the
* server, and adds a family of 'save' methods to the returned observable (see docs for
* addSaveInterface() above).
*/
function createField(saveFunc) {
return addSaveInterface(ko.observable(), saveFunc);
}
exports.createField = createField;
/**
* Returns an observable that mirrors another one but returns a default value if the underlying
* field is falsy. Supports writing and saving, which translates directly to writing to the
* underlying field. If the default value is a function, it's evaluated as in `computed()`, with
* the given context.
*/
function fieldWithDefault(fieldObs, defaultOrFunc, optContext) {
var obsWithDef = koUtil.observableWithDefault(fieldObs, defaultOrFunc, optContext);
if (fieldObs.saveOnly) {
addSaveInterface(obsWithDef, fieldObs.saveOnly);
}
return obsWithDef;
}
exports.fieldWithDefault = fieldWithDefault;
/**
* Helper to create an observable for a single property of a jsonObservable. It updates whenever
* the jsonObservable is updated, and it allows setting the property, which sets the entire object
* of the jsonObservable. Also supports 'save' methods.
*/
function _createJsonProp(jsonObservable, propName) {
var jsonProp = ko.pureComputed({
read: function() { return jsonObservable()[propName]; },
write: function(value) {
var obj = jsonObservable.peek();
obj[propName] = value;
jsonObservable(obj);
}
});
// Add save methods (if underlying jsonObservable supports them)
if (jsonObservable.saveOnly) {
addSaveInterface(jsonProp, function(value) {
var obj = _.clone(jsonObservable.peek());
obj[propName] = value;
return jsonObservable.saveOnly(obj);
});
}
return jsonProp;
}
/**
* Creates an observable for an object represented by an observable JSON string. It automatically
* parses the JSON string when it changes, and stringifies on setting the object. It also supports
* 'save' methods, forwarding calls to the .saveOnly function of the underlying string observable.
*
* @param {observable[String]} stringObservable: observable for a string that should contain JSON.
* @param [Function] modifierFunc: function called with parsed object, which can modify it
* at will, e.g. to set defaults. It's OK to modify in-place; only the return value is used.
* @param [Object] optContext: Optionally a context to call modifierFunc with.
*
* The returned observable supports these methods:
* @method save() Saves the current value of the observable to the server.
* @method saveOnly(obj) Saves the given value, without changing the observable's value.
* @method setAndSave(obj) Sets a new value for the observable and saves it.
* @method update(obj) Updates json with new properties (caller can .save() afterwards).
* @method prop(name) Returns an observable for the given property of the JSON object,
* which also supports saving. Multiple calls to prop('foo') return the same observable.
*/
function jsonObservable(stringObservable, modifierFunc, optContext) {
modifierFunc = modifierFunc || function(obj) { return obj || {}; };
// Create the jsonObservable itself
var obs = ko.pureComputed({
read: function() { // reads the underlying string, parses, and passes through modFunc
var json = stringObservable();
return modifierFunc.call(optContext, json ? JSON.parse(json) : null);
},
write: function(obj) { // stringifies the given obj and sets the underlying string to that
stringObservable(JSON.stringify(obj));
}
});
// Create save interface if possible
if (stringObservable.saveOnly) {
addSaveInterface(obs, function(obj) {
return stringObservable.saveOnly(JSON.stringify(obj));
});
}
return objObservable(obs);
}
exports.jsonObservable = jsonObservable;
/**
* Creates an observable for an object.
*
* @param {observable[Object]} objectObservable: observable for an object.
*
* The returned observable supports these methods:
* @method update(obj) Updates object with new properties.
* @method prop(name) Returns an observable for the given property of the object.
*/
function objObservable(objectObservable) {
objectObservable.update = function(obj) {
this(_.extend(this.peek(), obj)); // read self, _.extend, writeback
};
objectObservable._props = {};
objectObservable.prop = function(propName) {
// If created, return cached prop. Else _createJsonProp
return this._props[propName] || (this._props[propName] = _createJsonProp(this, propName));
};
return objectObservable;
}
exports.objObservable = objObservable;
// Special value that indicates that a customValueField isn't set and is using the saved value.
var _sentinel = {};
/**
* Creates a observable that reflects savedObservable() but may diverge from it when set, and has
* a methods to revert to the saved value. Additionally, the saving methods
* (.save/.saveOnly/.setAndSave) save savedObservable() and synchronize the values.
*/
function customValue(savedObservable) {
var options = { read: () => savedObservable() };
if (savedObservable.saveOnly) {
options.save = (val => savedObservable.saveOnly(val));
}
return customComputed(options);
}
exports.customValue = customValue;
/**
* Creates an observable whose value defaults to options.read() but may diverge from it when set,
* and has a method to revert to the default value. If options.save(val) is provided, the saving
* methods (.save/.saveOnly/.setAndSave) call it and reset the observable to its default value.
* @param {Function} options.read: Returns the default value for the observable.
* @param {Function} options.save(val): Saves a new value of the observable. May return a Promise.
*
* @returns {Observable} A writable observable value with some extra properties:
* @property {Observable} isSaved: Computed for whether customComputed() has its default value.
* @method revert(): Revert the customComputed() to its default value.
* @method save(val): If val is different from the current value of read(), call
* options.save(val), then revert the observable to its (possibly new) default value.
*/
function customComputed(options) {
var current = ko.observable(_sentinel);
var read = options.read;
var save = options.save;
// This is our main interface: just an observable, which defaults to the one at fieldName.
var active = ko.pureComputed({
read: () => (current() !== _sentinel ? current() : read()),
write: val => current(val !== read() ? val : _sentinel),
});
// .isSaved is an observable that returns whether the saved value has not been overridden.
active.isSaved = ko.pureComputed(() => (current() === _sentinel));
// .revert reverts to the saved value, discarding whatever custom value was set.
active.revert = function() { current(_sentinel); };
// When any of the .save/.saveOnly/.setAndSave functions are called on the customValueField,
// they save the underlying value and (when that resolves), discard the current value.
if (save) {
addSaveInterface(active, val => (
Promise.try(() => val !== read() ? save(val) : null).finally(active.revert)
));
}
return active;
}
exports.customComputed = customComputed;
function bulkActionExpand(bulkAction, callback, context) {
assert(gutil.startsWith(bulkAction[0], "Bulk"));
var rowIds = bulkAction[2];
var columnValues = bulkAction[3];
var indivAction = bulkAction.slice(0);
indivAction[0] = indivAction[0].slice(4);
var colValues = indivAction[3] = columnValues && _.clone(columnValues);
for (var i = 0; i < rowIds.length; i++) {
indivAction[2] = rowIds[i];
if (colValues) {
for (var col in colValues) {
colValues[col] = columnValues[col][i];
}
}
callback.call(context, indivAction);
}
}
exports.bulkActionExpand = bulkActionExpand;
/**
* Helper class which provides a `dispatchAction` method that can be subscribed to listen to
* actions received from the server. It dispatches each action to `this._process_{ActionType}`
* method, e.g. `this._process_UpdateRecord`.
*
* Implementation methods `_process_*` are called with the action as the first argument, and with
* the action arguments as additional method arguments, for convenience.
*/
var ActionDispatcher = {
dispatchAction: function(action) {
console.assert(!(typeof this.isDisposed === 'function' && this.isDisposed()),
`Dispatching action ${action[0]} on disposed object`, this);
var methodName = "_process_" + action[0];
var func = this[methodName];
if (typeof func === 'function') {
var args = action.slice(0);
args[0] = action;
return func.apply(this, args);
} else {
console.warn("Received unknown action %s", action[0]);
}
},
/**
* Generic handler for bulk actions (Bulk{Add,Remove,Update}Record) which forwards the bulk call
* to multiple per-record calls. Intended to be used as:
* Foo.prototype._process_BulkUpdateRecord = Foo.prototype.dispatchBulk;
*/
dispatchBulk: function(action, tableId, rowIds, columnValues) {
bulkActionExpand(action, this.dispatchAction, this);
},
};
exports.ActionDispatcher = ActionDispatcher;

645
app/client/models/rowset.ts Normal file
View File

@@ -0,0 +1,645 @@
/**
* rowset.js module defines a number of classes to deal with maintaining collections of rows and
* listening to their changes.
*
* RowSource: abstract interface for a source of row changes.
* - emits rowChange('add|remove|update', rows) events with rows an iterable.
* - offers getAllRows() method that returns all rows currently in the RowSource.
*
* RowListener: base class for a listener to row changes.
* - offers subscribeTo(rowSource), unsubscribeFrom(rowSource) methods.
* - derived classes should implement onAddRows(), onRemoveRows(), onUpdateRows().
*
* FilteredRowSource(filterFunc): a RowListener that can be subscribed to any other RowSources and
* is itself a RowSource which forwards changes to rows that match filterFunc.
*
* RowGrouping(groupFunc): a RowListener that can be subscribed to any RowSources, groups
* rows by the result of groupFunc, and exposes a per-group RowSource via its getGroup() method.
*
* SortedRowSet(compareFunc): a RowListener that can be subscribed to any RowSources, and exposes
* an observable koArray via getKoArray(), which maintains rows from RowSources in sorted order.
*/
// tslint:disable:max-classes-per-file
import koArray, {KoArray} from 'app/client/lib/koArray';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {CompareFunc, sortedIndex} from 'app/common/gutil';
/**
* Special constant value that can be used for the `rows` array for the 'rowNotify'
* event to indicate that the event applies to all rows.
*/
export const ALL: unique symbol = Symbol("ALL");
export type ChangeType = 'add' | 'remove' | 'update';
export type ChangeMethod = 'onAddRows' | 'onRemoveRows' | 'onUpdateRows';
export type RowId = number | string;
export type RowList = Iterable<RowId>;
export type RowsChanged = RowList | typeof ALL;
// ----------------------------------------------------------------------
// RowSource
// ----------------------------------------------------------------------
/**
* RowSource is an interface expected by RowListener. It should implement `getAllRows()` method,
* and should emit `rowChange('add|remove|update', rows)` events on changes,
* and `rowNotify(rows, value)` event to notify listeners of a value associated with a row.
* For the `rowNotify` event, rows may be the rowset.ALL constant.
*/
export class RowSource extends DisposableWithEvents {
/**
* Returns an iterable over all rows in this RowSource. Should be implemented by derived classes.
*/
public getAllRows(): RowList {
throw new Error("RowSource#getAllRows: Not implemented");
}
}
// ----------------------------------------------------------------------
// RowListener
// ----------------------------------------------------------------------
const _changeTypes: {[key: string]: ChangeMethod} = {
add: 'onAddRows',
remove: 'onRemoveRows',
update: 'onUpdateRows',
};
/**
* RowListener is the base class for collections that want to subscribe to rowset changes. It
* offers `subscribeTo(rowSource)` method. The derived class should implement several methods
* which will be called on row changes.
*/
export class RowListener extends DisposableWithEvents {
/**
* Subscribes to the given rowSource and adds the rows currently in it.
*/
public subscribeTo(rowSource: RowSource): void {
this.onAddRows(rowSource.getAllRows());
this.listenTo(rowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
const method: ChangeMethod = _changeTypes[changeType];
this[method](rows);
});
this.listenTo(rowSource, 'rowNotify', this.onRowNotify);
}
/**
* Unsubscribes from the given rowSource removing its rows. This is not needed for disposal;
* dispose() on its own is sufficient and faster.
*/
public unsubscribeFrom(rowSource: RowSource): void {
this.stopListening(rowSource, 'rowChange');
this.stopListening(rowSource, 'rowNotify');
this.onRemoveRows(rowSource.getAllRows());
}
/**
* Process row additions. To be implemented by derived classes.
*/
protected onAddRows(rows: RowList) { /* no-op */ }
/**
* Process row removals. To be implemented by derived classes.
*/
protected onRemoveRows(rows: RowList) { /* no-op */ }
/**
* Process row updates. To be implemented by derived classes.
*/
protected onUpdateRows(rows: RowList) { /* no-op */ }
/**
* Derived classes may override this event to handle row notifications. By default, it re-triggers
* rowNotify on the RowListener itself.
*/
protected onRowNotify(rows: RowList, notifyValue: any) {
this.trigger('rowNotify', rows, notifyValue);
}
}
// ----------------------------------------------------------------------
// MappedRowSource
// ----------------------------------------------------------------------
/**
* MappedRowSource wraps any other RowSource, and passes through all rows, replacing each row
* identifier with the result of mapperFunc(row) call.
*
* The underlying RowSource is exposed as this.parentRowSource.
*
* TODO: This class is not used anywhere at the moment, and is a candidate for removal.
*/
export class MappedRowSource extends RowSource {
private _mapperFunc: (row: RowId) => RowId;
constructor(
public parentRowSource: RowSource,
mapperFunc: (row: RowId) => RowId,
) {
super();
// Wrap mapperFunc to ensure arguments after the first one aren't passed on to it.
this._mapperFunc = (row => mapperFunc(row));
// Listen to the two event types a rowSource might produce, and map the rows in them.
this.listenTo(parentRowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
this.trigger('rowChange', changeType, Array.from(rows, this._mapperFunc));
});
this.listenTo(parentRowSource, 'rowNotify', (rows: RowsChanged, notifyValue: any) => {
this.trigger('rowNotify', rows === ALL ? ALL : Array.from(rows, this._mapperFunc), notifyValue);
});
}
public getAllRows(): RowList {
return Array.from(this.parentRowSource.getAllRows(), this._mapperFunc);
}
}
/**
* A RowSource with some extra rows added.
*/
export class ExtendedRowSource extends RowSource {
constructor(
public parentRowSource: RowSource,
public extras: RowId[]
) {
super();
// Listen to the two event types a rowSource might produce, and map the rows in them.
this.listenTo(parentRowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
this.trigger('rowChange', changeType, rows);
});
this.listenTo(parentRowSource, 'rowNotify', (rows: RowsChanged, notifyValue: any) => {
this.trigger('rowNotify', rows === ALL ? ALL : rows, notifyValue);
});
}
public getAllRows(): RowList {
return [...this.parentRowSource.getAllRows()].concat(this.extras);
}
}
// ----------------------------------------------------------------------
// FilteredRowSource
// ----------------------------------------------------------------------
export type FilterFunc = (row: RowId) => boolean;
interface FilterRowChanges {
adds?: RowId[];
updates?: RowId[];
removes?: RowId[];
}
/**
* See FilteredRowSource, for which this is the base. BaseFilteredRowSource is simpler, in that it
* does not maintain excluded rows, and does not allow changes to filterFunc.
*/
export class BaseFilteredRowSource extends RowListener implements RowSource {
protected _matchingRows: Set<RowId> = new Set(); // Set of rows matching the filter.
constructor(protected _filterFunc: FilterFunc) {
super();
}
public getAllRows(): RowList {
return this._matchingRows.values();
}
public onAddRows(rows: RowList) {
const outputRows = [];
for (const r of rows) {
if (this._filterFunc(r)) {
this._matchingRows.add(r);
outputRows.push(r);
} else {
this._addExcludedRow(r);
}
}
if (outputRows.length > 0) {
this.trigger('rowChange', 'add', outputRows);
}
}
public onRemoveRows(rows: RowList) {
const outputRows = [];
for (const r of rows) {
if (this._matchingRows.delete(r)) {
outputRows.push(r);
}
this._deleteExcludedRow(r);
}
if (outputRows.length > 0) {
this.trigger('rowChange', 'remove', outputRows);
}
}
public onUpdateRows(rows: RowList) {
const changes = this._updateRowsHelper({}, rows);
if (changes.removes) { this.trigger('rowChange', 'remove', changes.removes); }
if (changes.updates) { this.trigger('rowChange', 'update', changes.updates); }
if (changes.adds) { this.trigger('rowChange', 'add', changes.adds); }
}
public onRowNotify(rows: RowsChanged, notifyValue: any) {
if (rows === ALL) {
this.trigger('rowNotify', ALL, notifyValue);
} else {
const outputRows = [];
for (const r of rows) {
if (this._matchingRows.has(r)) {
outputRows.push(r);
}
}
if (outputRows.length > 0) {
this.trigger('rowNotify', outputRows, notifyValue);
}
}
}
/**
* Helper which goes through the given rows, applies _filterFunc() to them, and depending on the
* result, adds the row to one of the arrays: changes.adds, changes.removes, or changes.updates.
* Returns `changes` (the first parameter).
*/
protected _updateRowsHelper(changes: FilterRowChanges, rows: RowList) {
for (const r of rows) {
if (this._filterFunc(r)) {
if (this._matchingRows.has(r)) {
(changes.updates || (changes.updates = [])).push(r);
} else if (this._deleteExcludedRow(r)) {
this._matchingRows.add(r);
(changes.adds || (changes.adds = [])).push(r);
}
} else {
if (this._matchingRows.delete(r)) {
this._addExcludedRow(r);
(changes.removes || (changes.removes = [])).push(r);
}
}
}
return changes;
}
// These are implemented by FilteredRowSource, but the base class doesn't need to do anything.
protected _addExcludedRow(row: RowId): void { /* no-op */ }
protected _deleteExcludedRow(row: RowId): boolean { return true; }
}
/**
* FilteredRowSource can listen to any other RowSource, and passes through only the rows matching
* the given filter function. In particular, an 'update' event may turn into an 'add' or 'remove'
* if the row starts or stops matching the function.
*
* FilteredRowSource is also a RowListener, so to subscribe to a rowSource, use `subscribeTo()`.
*/
export class FilteredRowSource extends BaseFilteredRowSource {
private _excludedRows: Set<RowId> = new Set(); // Set of rows NOT matching the filter.
/**
* Change the filter function. This may trigger 'remove' and 'add' events as necessary to indicate
* that rows stopped or started matching the new filter.
*/
public updateFilter(filterFunc: FilterFunc) {
this._filterFunc = filterFunc;
const changes: FilterRowChanges = {};
// After the first call, _excludedRows may have additional rows, but there is no harm in it,
// as we know they don't match, and so will be ignored by _updateRowsHelper.
this._updateRowsHelper(changes, this._matchingRows);
this._updateRowsHelper(changes, this._excludedRows);
if (changes.removes) { this.trigger('rowChange', 'remove', changes.removes); }
if (changes.adds) { this.trigger('rowChange', 'add', changes.adds); }
}
/**
* Re-apply the filter to the given rows, triggering add/remove events as needed. This is also
* similar to what happens on an rowChange/update event from a RowSource, except that no 'update'
* event is propagated if filter status hasn't changed.
*/
public refilterRows(rows: RowList) {
const changes = this._updateRowsHelper({}, rows);
if (changes.removes) { this.trigger('rowChange', 'remove', changes.removes); }
if (changes.adds) { this.trigger('rowChange', 'add', changes.adds); }
}
/**
* Returns an iterable over all rows that got filtered out by this FilteredRowSource.
*/
public getHiddenRows() {
return this._excludedRows.values();
}
protected _addExcludedRow(row: RowId): void { this._excludedRows.add(row); }
protected _deleteExcludedRow(row: RowId): boolean { return this._excludedRows.delete(row); }
}
// ----------------------------------------------------------------------
// RowGrouping
// ----------------------------------------------------------------------
/**
* Private helper object that maintains a set of rows for a particular group.
*/
class RowGroupHelper<Value> extends RowSource {
private rows: Set<RowId> = new Set();
constructor(public readonly groupValue: Value) {
super();
}
public getAllRows() {
return this.rows.values();
}
public _addAll(rows: RowList) {
for (const r of rows) { this.rows.add(r); }
}
public _removeAll(rows: RowList) {
for (const r of rows) { this.rows.delete(r); }
}
}
// ----------------------------------------------------------------------
function _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {
let arr = map.get(key);
if (!arr) { map.set(key, arr = []); }
arr.push(r);
}
/**
* RowGrouping is a RowListener which groups rows by the results of _groupFunc(row) and exposes
* per-group RowSources via getGroup(val).
*
* @param {Function} groupFunc: called with row identifier, should return the value to group by.
* The returned value must be a primitive value such as a String or Number.
*/
export class RowGrouping<Value> extends RowListener {
// Maps row identifiers to groupValues.
private _rowsToValues: Map<RowId, Value> = new Map();
// Maps group values to RowGroupHelpers
private _valuesToGroups: Map<Value, RowGroupHelper<Value>> = new Map();
constructor(private _groupFunc: (row: RowId) => Value) {
super();
// On disposal, dispose all RowGroupHelpers that we maintain.
this.onDispose(() => {
for (const rowGroupHelper of this._valuesToGroups.values()) {
rowGroupHelper.dispose();
}
});
}
/**
* Returns a RowSource for the group of rows for which groupFunc(row) is equal to groupValue.
*/
public getGroup(groupValue: Value): RowGroupHelper<Value> {
let group = this._valuesToGroups.get(groupValue);
if (!group) {
group = new RowGroupHelper(groupValue);
this._valuesToGroups.set(groupValue, group);
}
return group;
}
// Implementation of the RowListener interface.
/**
* Helper function that does map.get(key).push(r), creating an Array for the given key if
* necessary.
*/
public onAddRows(rows: RowList) {
const groupedRows = new Map();
for (const r of rows) {
const newValue = this._groupFunc(r);
_addToMapOfArrays(groupedRows, newValue, r);
this._rowsToValues.set(r, newValue);
}
groupedRows.forEach((groupRows, groupValue) => {
const group = this.getGroup(groupValue);
group._addAll(groupRows);
group.trigger('rowChange', 'add', groupRows);
});
}
public onRemoveRows(rows: RowList) {
const groupedRows = new Map();
for (const r of rows) {
_addToMapOfArrays(groupedRows, this._rowsToValues.get(r), r);
this._rowsToValues.delete(r);
}
// Note that we don't dispose the RowGroupHelper itself when it becomes empty, because this
// group may be in use elsewhere (even if empty at the moment). RowGroupHelpers are only
// disposed together with the RowGrouping object itself.
groupedRows.forEach((groupRows, groupValue) => {
const group = this._valuesToGroups.get(groupValue)!;
group._removeAll(groupRows);
group.trigger('rowChange', 'remove', groupRows);
});
}
public onUpdateRows(rows: RowList) {
let updateGroup, removeGroup, insertGroup;
for (const r of rows) {
const oldValue = this._rowsToValues.get(r);
const newValue = this._groupFunc(r);
if (newValue === oldValue) {
_addToMapOfArrays(updateGroup || (updateGroup = new Map()), oldValue, r);
} else {
this._rowsToValues.set(r, newValue);
_addToMapOfArrays(removeGroup || (removeGroup = new Map()), oldValue, r);
_addToMapOfArrays(insertGroup || (insertGroup = new Map()), newValue, r);
}
}
if (removeGroup) {
removeGroup.forEach((groupRows, groupValue) => {
const group = this._valuesToGroups.get(groupValue)!;
group._removeAll(groupRows);
group.trigger('rowChange', 'remove', groupRows);
});
}
if (updateGroup) {
updateGroup.forEach((groupRows, groupValue) => {
const group = this._valuesToGroups.get(groupValue)!;
group.trigger('rowChange', 'update', groupRows);
});
}
if (insertGroup) {
insertGroup.forEach((groupRows, groupValue) => {
const group = this.getGroup(groupValue);
group._addAll(groupRows);
group.trigger('rowChange', 'add', groupRows);
});
}
}
public onRowNotify(rows: RowsChanged, notifyValue: any) {
if (rows === ALL) {
for (const group of this._valuesToGroups.values()) {
group.trigger('rowNotify', ALL, notifyValue);
}
} else {
const groupedRows = new Map();
for (const r of rows) {
_addToMapOfArrays(groupedRows, this._rowsToValues.get(r), r);
}
groupedRows.forEach((groupRows, groupValue) => {
const group = this._valuesToGroups.get(groupValue)!;
group.trigger('rowNotify', groupRows, notifyValue);
});
}
}
}
// ----------------------------------------------------------------------
// SortedRowSet
// ----------------------------------------------------------------------
/**
* SortedRowSet is a RowListener which maintains a set of rows in a sorted order, according to the
* results of compareFunc. The sorted rows are exposed as an observable koArray.
*
* SortedRowSet re-emits 'rowNotify(rows, value)' events from RowSources that it subscribes to.
*/
export class SortedRowSet extends RowListener {
private _allRows: Set<RowId> = new Set();
private _isPaused: boolean = false;
private _koArray: KoArray<RowId>;
constructor(private _compareFunc: CompareFunc<RowId>) {
super();
this._koArray = this.autoDispose(koArray<RowId>());
}
/**
* Returns the sorted observable koArray maintained by this SortedRowSet.
*/
public getKoArray() {
return this._koArray;
}
/**
* Disable the populating of koArray temporarily. When pause(false) is called, the array is
* brought back up to date. This is useful if there are multiple changes, e.g. subscriptions and
* compareFunc updates, to avoid sorting multiple times.
*/
public pause(doPause: boolean) {
if (!doPause && this._isPaused) {
this._koArray.assign(Array.from(this._allRows).sort(this._compareFunc));
}
this._isPaused = Boolean(doPause);
}
/**
* Re-sorts the array according to the new compareFunc.
*/
public updateSort(compareFunc: CompareFunc<RowId>): void {
this._compareFunc = compareFunc;
if (!this._isPaused) {
this._koArray.assign(Array.from(this._koArray.peek()).sort(this._compareFunc));
}
}
public onAddRows(rows: RowList) {
for (const r of rows) {
this._allRows.add(r);
}
if (this._isPaused) {
return;
}
if (isSmallChange(rows)) {
for (const r of rows) {
const insertIndex = sortedIndex(this._koArray.peek(), r, this._compareFunc);
this._koArray.splice(insertIndex, 0, r);
}
} else {
this._koArray.assign(Array.from(this._allRows).sort(this._compareFunc));
}
}
public onRemoveRows(rows: RowList) {
for (const r of rows) {
this._allRows.delete(r);
}
if (this._isPaused) {
return;
}
if (isSmallChange(rows)) {
for (const r of rows) {
const index = this._koArray.peek().indexOf(r);
if (index !== -1) {
this._koArray.splice(index, 1);
}
}
} else {
this._koArray.assign(Array.from(this._allRows).sort(this._compareFunc));
}
}
public onUpdateRows(rows: RowList) {
// If paused, do nothing, since we'll re-sort later anyway.
if (this._isPaused) {
return;
}
// If all affected rows are in correct place relative to their neighbors, then the array is
// still sorted, and there is nothing to do. (It's a common case when the update affects fields
// not participating in the sort.)
//
// Note that the logic is all or none, since we can't assume that a single row is in its right
// place by comparing to neighbors because the neighbors might themselves be affected and wrong.
const sortedRows = Array.from(rows).sort(this._compareFunc);
if (_allRowsSorted(this._koArray.peek(), sortedRows, this._compareFunc)) {
return;
}
if (isSmallChange(rows)) {
// Note that we can't add any rows before we remove all affected rows, because affected rows
// may no longer be in the correct sort order, so binary search is broken until they are gone.
this.onRemoveRows(rows);
this.onAddRows(rows);
} else {
this._koArray.assign(Array.from(this._koArray.peek()).sort(this._compareFunc));
}
}
}
function isSmallChange(rows: RowList) {
return Array.isArray(rows) && rows.length <= 2;
}
/**
* Helper function to tell if array[index] is in order relative to its neighbors.
*/
function _isIndexInOrder<T>(array: T[], index: number, compareFunc: CompareFunc<T>): boolean {
const r = array[index];
return ((index === 0 || compareFunc(array[index - 1], r) <= 0) &&
(index === array.length - 1 || compareFunc(r, array[index + 1]) <= 0));
}
/**
* Helper function to tell if each of sortedRows, if present in the array, is in order relative to
* its neighbors. sortedRows should be sorted the same way as the array.
*/
function _allRowsSorted<T>(array: T[], sortedRows: Iterable<T>, compareFunc: CompareFunc<T>): boolean {
let last = 0;
for (const r of sortedRows) {
const index = array.indexOf(r, last);
if (index === -1) { continue; }
if (!_isIndexInOrder(array, index, compareFunc)) {
return false;
}
last = index;
}
return true;
}

View File

@@ -0,0 +1,48 @@
/**
* For some purposes, we need to identify rows uniquely across different tables, e.g. when showing
* data with subtotals. This module implements a simple and reasonably efficient way to combine a
* tableRef and rowId into a single numeric identifier.
*/
// A JS Number can represent integers exactly up to 53 bits. We use some of those bits to
// represent tableRef (the rowId of the table in _grist_Tables meta table), and the rest to
// represent rowId in the table. Note that we currently never reuse old ids, so these limits apply
// to the count of all tables or all rows per table that ever existed, including deleted ones.
const MAX_TABLES = Math.pow(2, 18); // Up to ~262k tables.
const MAX_ROWS = Math.pow(2, 35); // Up to ~34 billion rows.
exports.MAX_TABLES = MAX_TABLES;
exports.MAX_ROWS = MAX_ROWS;
/**
* Given tableRef and rowId, returns a Number combining them.
*/
function combine(tableRef, rowId) {
return tableRef * MAX_ROWS + rowId;
}
exports.combine = combine;
/**
* Given a combined rowUid, returns the tableRef it represents.
*/
function tableRef(rowUid) {
return Math.floor(rowUid / MAX_ROWS);
}
exports.tableRef = tableRef;
/**
* Given a combined rowUid, returns the rowId it represents.
*/
function rowId(rowUid) {
return rowUid % MAX_ROWS;
}
exports.rowId = rowId;
/**
* Returns a human-readable string representation of the rowUid, as "tableRef:rowId".
*/
function toString(rowUid) {
return typeof rowUid === 'number' ? tableRef(rowUid) + ":" + rowId(rowUid) : rowUid;
}
exports.toString = toString;