(core) Crudely show row count and limit in UI

Summary:
Add rowCount returned from sandbox when applying user actions to ActionGroup which is broadcast to clients.

Add rowCount to ActiveDoc and update it after applying user actions.

Add rowCount to OpenLocalDocResult using ActiveDoc value, to show when a client opens a doc before any user actions happen.

Add rowCount observable to DocPageModel which is set when the doc is opened and when action groups are received.

Add crude UI (commented out) in Tool.ts showing the row count and the limit in AppModel.currentFeatures. The actual UI doesn't have a place to go yet.

Followup tasks:

- Real, pretty UI
- Counts per table
- Keep count(s) secret from users with limited access?
- Data size indicator?
- Banner when close to or above limit
- Measure row counts outside of sandbox to avoid spoofing with formula
- Handle changes to the limit when the plan is changed or extra rows are purchased

Test Plan: Tested UI manually, including with free team site, opening a fresh doc, opening an initialised doc, adding rows, undoing, and changes from another tab. Automated tests seem like they should wait for a proper UI.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3318
This commit is contained in:
Alex Hall 2022-03-14 20:16:30 +02:00
parent d154b9afa7
commit 02e69fb685
9 changed files with 38 additions and 8 deletions

View File

@ -452,6 +452,9 @@ export class GristDoc extends DisposableWithEvents {
if (schemaUpdated) { if (schemaUpdated) {
this.trigger('schemaUpdateAction', docActions); this.trigger('schemaUpdateAction', docActions);
} }
if (typeof actionGroup.rowCount === "number") {
this.docPageModel.rowCount.set(actionGroup.rowCount);
}
} }
} }

View File

@ -65,6 +65,8 @@ export interface DocPageModel {
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists. gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
rowCount: Observable<number|undefined>;
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg; createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
renameDoc(value: string): Promise<void>; renameDoc(value: string): Promise<void>;
updateCurrentDoc(urlId: string, openMode: OpenDocMode): Promise<Document>; updateCurrentDoc(urlId: string, openMode: OpenDocMode): Promise<Document>;
@ -105,6 +107,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
// Observable set to the instance of GristDoc once it's created. // Observable set to the instance of GristDoc once it's created.
public readonly gristDoc = Observable.create<GristDoc|null>(this, null); public readonly gristDoc = Observable.create<GristDoc|null>(this, null);
public readonly rowCount = Observable.create<number|undefined>(this, undefined);
// Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the // 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. // 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 // If making a comparison, the id of the document we are comparing with is also included
@ -253,6 +257,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
doc.userOverride = openDocResponse.userOverride || null; doc.userOverride = openDocResponse.userOverride || null;
this.currentDoc.set({...doc}); this.currentDoc.set({...doc});
} }
this.rowCount.set(openDocResponse.rowCount);
const gdModule = await gristDocModulePromise; const gdModule = await gristDocModulePromise;
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier); const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
flow.checkIfCancelled(); flow.checkIfCancelled();

View File

@ -19,8 +19,9 @@ import {Computed, Disposable, dom, makeTestId, Observable, observable, styled} f
const testId = makeTestId('test-tools-'); const testId = makeTestId('test-tools-');
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element { export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
const isOwner = gristDoc.docPageModel.currentDoc.get()?.access === 'owners'; const docPageModel = gristDoc.docPageModel;
const isOverridden = Boolean(gristDoc.docPageModel.userOverride.get()); const isOwner = docPageModel.currentDoc.get()?.access === 'owners';
const isOverridden = Boolean(docPageModel.userOverride.get());
const hasDocTour = Computed.create(owner, use => const hasDocTour = Computed.create(owner, use =>
use(gristDoc.docModel.allTableIds.getObservable()).includes('GristDocTour')); use(gristDoc.docModel.allTableIds.getObservable()).includes('GristDocTour'));
const canViewAccessRules = observable(false); const canViewAccessRules = observable(false);
@ -34,6 +35,13 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)), cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
cssSectionHeader("TOOLS"), cssSectionHeader("TOOLS"),
// TODO proper UI
// cssPageEntry(
// dom.domComputed(docPageModel.rowCount, rowCount =>
// `${rowCount} of ${docPageModel.appModel.currentFeatures.baseMaxRowsPerDocument || "infinity"} rows used`
// ),
// ),
cssPageEntry( cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'), cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
cssPageEntry.cls('-disabled', (use) => !use(canViewAccessRules)), cssPageEntry.cls('-disabled', (use) => !use(canViewAccessRules)),
@ -78,7 +86,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
testId('code'), testId('code'),
), ),
cssSpacer(), cssSpacer(),
dom.maybe(gristDoc.docPageModel.currentDoc, (doc) => { dom.maybe(docPageModel.currentDoc, (doc) => {
const ex = examples.find(e => e.urlId === doc.urlId); const ex = examples.find(e => e.urlId === doc.urlId);
if (!ex || !ex.tutorialUrl) { return null; } if (!ex || !ex.tutorialUrl) { return null; }
return cssPageEntry( return cssPageEntry(
@ -125,7 +133,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
) )
), ),
), ),
createHelpTools(gristDoc.docPageModel.appModel, false) createHelpTools(docPageModel.appModel, false)
); );
} }

View File

@ -25,4 +25,5 @@ export interface ActionGroup extends MinimalActionGroup {
user: string; user: string;
primaryAction: string; // The name of the first user action in the ActionGroup. primaryAction: string; // The name of the first user action in the ActionGroup.
internal: boolean; // True if it is inappropriate to log/undo the action. internal: boolean; // True if it is inappropriate to log/undo the action.
rowCount?: number;
} }

View File

@ -44,6 +44,7 @@ export interface OpenLocalDocResult {
log: MinimalActionGroup[]; log: MinimalActionGroup[];
recoveryMode?: boolean; recoveryMode?: boolean;
userOverride?: UserOverride; userOverride?: UserOverride;
rowCount?: number;
} }
export interface UserOverride { export interface UserOverride {

View File

@ -159,6 +159,7 @@ export class ActiveDoc extends EventEmitter {
private _fullyLoaded: boolean = false; // Becomes true once all columns are loaded/computed. private _fullyLoaded: boolean = false; // Becomes true once all columns are loaded/computed.
private _lastMemoryMeasurement: number = 0; // Timestamp when memory was last measured. private _lastMemoryMeasurement: number = 0; // Timestamp when memory was last measured.
private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL); private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL);
private _rowCount?: number;
// Timer for shutting down the ActiveDoc a bit after all clients are gone. // Timer for shutting down the ActiveDoc a bit after all clients are gone.
private _inactivityTimer = new InactivityTimer(() => this.shutdown(), Deps.ACTIVEDOC_TIMEOUT * 1000); private _inactivityTimer = new InactivityTimer(() => this.shutdown(), Deps.ACTIVEDOC_TIMEOUT * 1000);
@ -212,6 +213,12 @@ export class ActiveDoc extends EventEmitter {
public get isShuttingDown(): boolean { return this._shuttingDown; } public get isShuttingDown(): boolean { return this._shuttingDown; }
public async getRowCount(docSession: OptDocSession): Promise<number | undefined> {
if (await this._granularAccess.canReadEverything(docSession)) {
return this._rowCount;
}
}
public async getUserOverride(docSession: OptDocSession) { public async getUserOverride(docSession: OptDocSession) {
return this._granularAccess.getUserOverride(docSession); return this._granularAccess.getUserOverride(docSession);
} }
@ -1221,6 +1228,7 @@ export class ActiveDoc extends EventEmitter {
...this.getLogMeta(docSession), ...this.getLogMeta(docSession),
rowCount: sandboxActionBundle.rowCount rowCount: sandboxActionBundle.rowCount
}); });
this._rowCount = sandboxActionBundle.rowCount;
await this._reportDataEngineMemory(); await this._reportDataEngineMemory();
} else { } else {
// Create default SandboxActionBundle to use if the data engine is not called. // Create default SandboxActionBundle to use if the data engine is not called.

View File

@ -311,9 +311,11 @@ export class DocManager extends EventEmitter {
} }
} }
const [metaTables, recentActions] = await Promise.all([ const [metaTables, recentActions, userOverride, rowCount] = await Promise.all([
activeDoc.fetchMetaTables(docSession), activeDoc.fetchMetaTables(docSession),
activeDoc.getRecentMinimalActions(docSession) activeDoc.getRecentMinimalActions(docSession),
activeDoc.getUserOverride(docSession),
activeDoc.getRowCount(docSession),
]); ]);
const result = { const result = {
@ -322,7 +324,8 @@ export class DocManager extends EventEmitter {
doc: metaTables, doc: metaTables,
log: recentActions, log: recentActions,
recoveryMode: activeDoc.recoveryMode, recoveryMode: activeDoc.recoveryMode,
userOverride: await activeDoc.getUserOverride(docSession), userOverride,
rowCount,
} as OpenLocalDocResult; } as OpenLocalDocResult;
if (!activeDoc.muted) { if (!activeDoc.muted) {

View File

@ -365,10 +365,10 @@ export class GranularAccess implements GranularAccessForBundle {
public async filterActionGroup(docSession: OptDocSession, actionGroup: ActionGroup): Promise<ActionGroup> { public async filterActionGroup(docSession: OptDocSession, actionGroup: ActionGroup): Promise<ActionGroup> {
if (await this.allowActionGroup(docSession, actionGroup)) { return actionGroup; } if (await this.allowActionGroup(docSession, actionGroup)) { return actionGroup; }
// For now, if there's any nuance at all, suppress the summary and description. // For now, if there's any nuance at all, suppress the summary and description.
// TODO: create an empty action summary, to be sure not to leak anything important.
const result: ActionGroup = { ...actionGroup }; const result: ActionGroup = { ...actionGroup };
result.actionSummary = createEmptyActionSummary(); result.actionSummary = createEmptyActionSummary();
result.desc = ''; result.desc = '';
result.rowCount = undefined;
return result; return result;
} }

View File

@ -309,6 +309,7 @@ export class Sharing {
internal: isCalculate, internal: isCalculate,
}); });
actionGroup.actionSummary = actionSummary; actionGroup.actionSummary = actionSummary;
actionGroup.rowCount = sandboxActionBundle.rowCount;
await accessControl.appliedBundle(); await accessControl.appliedBundle();
await accessControl.sendDocUpdateForBundle(actionGroup); await accessControl.sendDocUpdateForBundle(actionGroup);
if (docSession) { if (docSession) {