mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
162
app/client/models/AppModel.ts
Normal file
162
app/client/models/AppModel.ts
Normal 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);
|
||||
}
|
||||
136
app/client/models/BaseRowModel.js
Normal file
136
app/client/models/BaseRowModel.js
Normal 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;
|
||||
256
app/client/models/BillingModel.ts
Normal file
256
app/client/models/BillingModel.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/client/models/ClientColumnGetters.ts
Normal file
28
app/client/models/ClientColumnGetters.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
82
app/client/models/ColumnACIndexes.ts
Normal file
82
app/client/models/ColumnACIndexes.ts
Normal 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);
|
||||
}
|
||||
109
app/client/models/ColumnFilter.ts
Normal file
109
app/client/models/ColumnFilter.ts
Normal 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":[]}';
|
||||
41
app/client/models/ConnectState.ts
Normal file
41
app/client/models/ConnectState.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
107
app/client/models/DataRowModel.ts
Normal file
107
app/client/models/DataRowModel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
319
app/client/models/DataTableModel.js
Normal file
319
app/client/models/DataTableModel.js
Normal 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;
|
||||
196
app/client/models/DataTableModelWithDiff.ts
Normal file
196
app/client/models/DataTableModelWithDiff.ts
Normal 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];
|
||||
}
|
||||
185
app/client/models/DocData.ts
Normal file
185
app/client/models/DocData.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
118
app/client/models/DocListModel.js
Normal file
118
app/client/models/DocListModel.js
Normal 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;
|
||||
217
app/client/models/DocModel.ts
Normal file
217
app/client/models/DocModel.ts
Normal 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');
|
||||
}
|
||||
338
app/client/models/DocPageModel.ts
Normal file
338
app/client/models/DocPageModel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
333
app/client/models/HomeModel.ts
Normal file
333
app/client/models/HomeModel.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
83
app/client/models/MetaRowModel.js
Normal file
83
app/client/models/MetaRowModel.js
Normal 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;
|
||||
241
app/client/models/MetaTableModel.js
Normal file
241
app/client/models/MetaTableModel.js
Normal 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;
|
||||
369
app/client/models/NotifyModel.ts
Normal file
369
app/client/models/NotifyModel.ts
Normal 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.'};
|
||||
}
|
||||
}
|
||||
359
app/client/models/QuerySet.ts
Normal file
359
app/client/models/QuerySet.ts
Normal 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())));
|
||||
}
|
||||
338
app/client/models/SearchModel.ts
Normal file
338
app/client/models/SearchModel.ts
Normal 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');
|
||||
}
|
||||
92
app/client/models/SectionFilter.ts
Normal file
92
app/client/models/SectionFilter.ts
Normal 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([]);
|
||||
}
|
||||
}
|
||||
141
app/client/models/TableData.ts
Normal file
141
app/client/models/TableData.ts
Normal 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));
|
||||
};
|
||||
}
|
||||
114
app/client/models/TableModel.js
Normal file
114
app/client/models/TableModel.js
Normal 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;
|
||||
261
app/client/models/TreeModel.ts
Normal file
261
app/client/models/TreeModel.ts
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
316
app/client/models/UserManagerModel.ts
Normal file
316
app/client/models/UserManagerModel.ts
Normal 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;
|
||||
}
|
||||
41
app/client/models/UserPrefs.ts
Normal file
41
app/client/models/UserPrefs.ts
Normal 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}));
|
||||
}
|
||||
55
app/client/models/WorkspaceInfo.ts
Normal file
55
app/client/models/WorkspaceInfo.ts
Normal 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};
|
||||
}
|
||||
14
app/client/models/entities/ACLMembershipRec.ts
Normal file
14
app/client/models/entities/ACLMembershipRec.ts
Normal 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);
|
||||
}
|
||||
29
app/client/models/entities/ACLPrincipalRec.ts
Normal file
29
app/client/models/entities/ACLPrincipalRec.ts
Normal 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()));
|
||||
}
|
||||
7
app/client/models/entities/ACLResourceRec.ts
Normal file
7
app/client/models/entities/ACLResourceRec.ts
Normal 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
|
||||
}
|
||||
92
app/client/models/entities/ColumnRec.ts
Normal file
92
app/client/models/entities/ColumnRec.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
19
app/client/models/entities/DocInfoRec.ts
Normal file
19
app/client/models/entities/DocInfoRec.ts
Normal 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;
|
||||
}));
|
||||
}
|
||||
11
app/client/models/entities/PageRec.ts
Normal file
11
app/client/models/entities/PageRec.ts
Normal 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);
|
||||
}
|
||||
8
app/client/models/entities/REPLRec.ts
Normal file
8
app/client/models/entities/REPLRec.ts
Normal 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
|
||||
}
|
||||
11
app/client/models/entities/TabBarRec.ts
Normal file
11
app/client/models/entities/TabBarRec.ts
Normal 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);
|
||||
}
|
||||
77
app/client/models/entities/TableRec.ts
Normal file
77
app/client/models/entities/TableRec.ts
Normal 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));
|
||||
}
|
||||
13
app/client/models/entities/TableViewRec.ts
Normal file
13
app/client/models/entities/TableViewRec.ts
Normal 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);
|
||||
}
|
||||
8
app/client/models/entities/ValidationRec.ts
Normal file
8
app/client/models/entities/ValidationRec.ts
Normal 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
|
||||
}
|
||||
204
app/client/models/entities/ViewFieldRec.ts
Normal file
204
app/client/models/entities/ViewFieldRec.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
53
app/client/models/entities/ViewRec.ts
Normal file
53
app/client/models/entities/ViewRec.ts
Normal 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);
|
||||
}
|
||||
275
app/client/models/entities/ViewSectionRec.ts
Normal file
275
app/client/models/entities/ViewSectionRec.ts
Normal 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
149
app/client/models/errors.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
62
app/client/models/gristConfigCache.ts
Normal file
62
app/client/models/gristConfigCache.ts
Normal 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;
|
||||
}
|
||||
320
app/client/models/modelUtil.js
Normal file
320
app/client/models/modelUtil.js
Normal 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
645
app/client/models/rowset.ts
Normal 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;
|
||||
}
|
||||
48
app/client/models/rowuid.js
Normal file
48
app/client/models/rowuid.js
Normal 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;
|
||||
Reference in New Issue
Block a user