(core) Keep track of row counts per table

Summary: Displays a live row count of each table on the Raw Data page.

Test Plan: Browser tests.

Reviewers: alexmojaki

Reviewed By: alexmojaki

Differential Revision: https://phab.getgrist.com/D3540
This commit is contained in:
George Gevoian 2022-08-03 00:18:21 -07:00
parent 40c9b8b7e8
commit 771e1edd54
8 changed files with 84 additions and 107 deletions

View File

@ -1,12 +1,10 @@
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';
import {TableRec} from 'app/client/models/DocModel'; import {TableRec} from 'app/client/models/DocModel';
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
import {showTransientTooltip} from 'app/client/ui/tooltips'; import {showTransientTooltip} from 'app/client/ui/tooltips';
import {buildTableName} from 'app/client/ui/WidgetTitle'; import {buildTableName} from 'app/client/ui/WidgetTitle';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
import * as css from 'app/client/ui2018/cssVars'; import * as css from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
@ -17,7 +15,13 @@ const testId = makeTestId('test-raw-data-');
export class DataTables extends Disposable { export class DataTables extends Disposable {
private _tables: Observable<TableRec[]>; private _tables: Observable<TableRec[]>;
private _view: Observable<string | null>;
private readonly _rowCount = Computed.create(
this, this._gristDoc.docPageModel.currentDocUsage, (_use, usage) => {
return usage?.rowCount;
}
);
constructor(private _gristDoc: GristDoc) { constructor(private _gristDoc: GristDoc) {
super(); super();
this._tables = Computed.create(this, use => { this._tables = Computed.create(this, use => {
@ -26,10 +30,6 @@ export class DataTables extends Disposable {
// Remove tables that we don't have access to. ACL will remove tableId from those tables. // Remove tables that we don't have access to. ACL will remove tableId from those tables.
return [...dataTables, ...summaryTables].filter(t => Boolean(use(t.tableId))); return [...dataTables, ...summaryTables].filter(t => Boolean(use(t.tableId)));
}); });
// Get the user id, to remember selected layout on the next visit.
const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0;
this._view = this.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list"));
} }
public buildDom() { public buildDom() {
@ -37,22 +37,8 @@ export class DataTables extends Disposable {
cssTableList( cssTableList(
/*************** List section **********/ /*************** List section **********/
testId('list'), testId('list'),
cssBetween( docListHeader('Raw Data Tables'),
docListHeader('Raw Data Tables'),
cssSwitch(
buttonSelect<any>(
this._view,
[
{value: 'card', icon: 'TypeTable'},
{value: 'list', icon: 'TypeCardList'},
],
css.testId('view-mode'),
cssButtonSelect.cls("-light")
)
)
),
cssList( cssList(
cssList.cls(use => `-${use(this._view)}`),
dom.forEach(this._tables, tableRec => dom.forEach(this._tables, tableRec =>
cssItem( cssItem(
testId('table'), testId('table'),
@ -63,10 +49,10 @@ export class DataTables extends Disposable {
)), )),
), ),
cssMiddle( cssMiddle(
css60(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))), cssTitleRow(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))),
css40( cssDetailsRow(
cssIdHoverWrapper( cssTableIdWrapper(cssHoverWrapper(
cssUpperCase("Table id: "), cssUpperCase("Table ID: "),
cssTableId( cssTableId(
testId('table-id'), testId('table-id'),
dom.text(tableRec.tableId), dom.text(tableRec.tableId),
@ -75,13 +61,14 @@ export class DataTables extends Disposable {
dom.on('click', async (e, t) => { dom.on('click', async (e, t) => {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
e.preventDefault(); e.preventDefault();
showTransientTooltip(t, 'Table id copied to clipboard', { showTransientTooltip(t, 'Table ID copied to clipboard', {
key: 'copy-table-id' key: 'copy-table-id'
}); });
await copyToClipboard(tableRec.tableId.peek()); await copyToClipboard(tableRec.tableId.peek());
setTestState({clipboard: tableRec.tableId.peek()}); setTestState({clipboard: tableRec.tableId.peek()});
}) })
) )),
this._tableRows(tableRec),
), ),
), ),
cssRight( cssRight(
@ -150,6 +137,21 @@ export class DataTables extends Disposable {
} }
confirmModal(`Delete ${t.formattedTableName()} data, and remove it from all pages?`, 'Delete', doRemove); confirmModal(`Delete ${t.formattedTableName()} data, and remove it from all pages?`, 'Delete', doRemove);
} }
private _tableRows(table: TableRec) {
return cssTableRowsWrapper(
cssUpperCase("Rows: "),
cssTableRows(
testId('table-rows'),
dom.text(use => {
const rowCounts = use(this._rowCount);
if (typeof rowCounts !== 'object') { return ''; }
return rowCounts[table.getRowId()]?.toString() ?? '';
}),
),
);
}
} }
const container = styled('div', ` const container = styled('div', `
@ -157,38 +159,10 @@ const container = styled('div', `
position: relative; position: relative;
`); `);
const cssBetween = styled('div', `
display: flex;
justify-content: space-between;
`);
// Below styles makes the list view look like a card view
// on smaller screens.
const cssSwitch = styled('div', `
@media ${css.mediaXSmall} {
& {
display: none;
}
}
`);
const cssList = styled('div', ` const cssList = styled('div', `
display: flex; display: flex;
&-list { flex-direction: column;
flex-direction: column; gap: 12px;
gap: 8px;
}
&-card {
flex-direction: row;
flex-wrap: wrap;
gap: 24px;
}
@media ${css.mediaSmall} {
& {
gap: 12px !important;
}
}
`); `);
const cssItem = styled('div', ` const cssItem = styled('div', `
@ -196,29 +170,13 @@ const cssItem = styled('div', `
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 3px;
width: 100%;
height: calc(1em * 56/13); /* 56px for 13px font */
max-width: 750px; max-width: 750px;
border: 1px solid ${css.colors.mediumGrey}; border: 1px solid ${css.colors.mediumGrey};
&:hover { &:hover {
border-color: ${css.colors.slate}; border-color: ${css.colors.slate};
} }
.${cssList.className}-list & {
min-height: calc(1em * 40/13); /* 40px for 13px font */
}
.${cssList.className}-card & {
width: 300px;
height: calc(1em * 56/13); /* 56px for 13px font */
}
@media ${css.mediaSmall} {
.${cssList.className}-card & {
width: calc(50% - 12px);
}
}
@media ${css.mediaXSmall} {
& {
width: 100% !important;
height: calc(1em * 56/13) !important; /* 56px for 13px font */
}
}
`); `);
// Holds icon in top left corner // Holds icon in top left corner
@ -238,21 +196,17 @@ const cssMiddle = styled('div', `
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 6px; margin-top: 6px;
margin-bottom: 4px; margin-bottom: 4px;
.${cssList.className}-card & {
margin: 0px;
}
`); `);
const css60 = styled('div', ` const cssTitleRow = styled('div', `
min-width: min(240px, 100%); min-width: 100%;
flex: 6;
margin-right: 4px; margin-right: 4px;
`); `);
const css40 = styled('div', ` const cssDetailsRow = styled('div', `
min-width: min(240px, 100%); min-width: 100%;
flex: 4;
display: flex; display: flex;
gap: 8px;
`); `);
@ -275,29 +229,43 @@ const cssLine = styled('span', `
overflow: hidden; overflow: hidden;
`); `);
const cssIdHoverWrapper = styled('div', ` const cssTableIdWrapper = styled('div', `
display: flex;
flex-grow: 1;
min-width: 0;
`);
const cssTableRowsWrapper = styled('div', `
display: flex;
flex-shrink: 0;
width: 80px;
overflow: hidden;
align-items: baseline;
color: ${css.colors.slate};
line-height: 18px;
padding: 0px 2px;
`);
const cssHoverWrapper = styled('div', `
display: flex; display: flex;
overflow: hidden; overflow: hidden;
cursor: default; cursor: default;
align-items: baseline; align-items: baseline;
color: ${css.colors.slate}; color: ${css.colors.slate};
transition: background 0.05s; transition: background 0.05s;
padding: 1px 2px; padding: 0px 2px;
line-height: 18px; line-height: 18px;
&:hover { &:hover {
background: ${css.colors.lightGrey}; background: ${css.colors.lightGrey};
} }
@media ${css.mediaSmall} {
& {
padding: 0px 2px !important;
}
}
`); `);
const cssTableId = styled(cssLine, ` const cssTableId = styled(cssLine, `
font-size: ${css.vars.smallFontSize}; font-size: ${css.vars.smallFontSize};
`); `);
const cssTableRows = cssTableId;
const cssTableTitle = styled('div', ` const cssTableTitle = styled('div', `
white-space: nowrap; white-space: nowrap;
`); `);

View File

@ -53,14 +53,14 @@ export class DocumentUsage extends Disposable {
private readonly _rowMetrics: Computed<MetricOptions | null> = private readonly _rowMetrics: Computed<MetricOptions | null> =
Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => { Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => {
const features = product?.features; const features = product?.features;
if (!features || typeof rowCount !== 'number') { return null; } if (!features || typeof rowCount !== 'object') { return null; }
const {baseMaxRowsPerDocument: maxRows} = features; const {baseMaxRowsPerDocument: maxRows} = features;
// Invalid row limits are currently treated as if they are undefined. // Invalid row limits are currently treated as if they are undefined.
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined; const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
return { return {
name: 'Rows', name: 'Rows',
currentValue: rowCount, currentValue: rowCount.total,
maximumValue: maxValue ?? DEFAULT_MAX_ROWS, maximumValue: maxValue ?? DEFAULT_MAX_ROWS,
unit: 'rows', unit: 'rows',
shouldHideLimits: maxValue === undefined, shouldHideLimits: maxValue === undefined,

View File

@ -4,6 +4,7 @@
*/ */
import {DocAction, UserAction} from 'app/common/DocActions'; import {DocAction, UserAction} from 'app/common/DocActions';
import {RowCounts} from 'app/common/DocUsage';
// Metadata about the action. // Metadata about the action.
export interface ActionInfo { export interface ActionInfo {
@ -61,7 +62,7 @@ export interface SandboxActionBundle {
calc: Array<EnvContent<DocAction>>; calc: Array<EnvContent<DocAction>>;
undo: Array<EnvContent<DocAction>>; // Inverse actions for all 'stored' actions. undo: Array<EnvContent<DocAction>>; // Inverse actions for all 'stored' actions.
retValues: any[]; // Contains retValue for each of userActions. retValues: any[]; // Contains retValue for each of userActions.
rowCount: number; rowCount: RowCounts;
// Mapping of keys (hashes of request args) to all unique requests made in a round of calculation // Mapping of keys (hashes of request args) to all unique requests made in a round of calculation
requests?: Record<string, SandboxRequest>; requests?: Record<string, SandboxRequest>;
} }

View File

@ -53,7 +53,7 @@ export function getDataLimitRatio(
const {rowCount, dataSizeBytes} = docUsage; const {rowCount, dataSizeBytes} = docUsage;
const maxRows = productFeatures?.baseMaxRowsPerDocument; const maxRows = productFeatures?.baseMaxRowsPerDocument;
const maxDataSize = productFeatures?.baseMaxDataSizePerDocument; const maxDataSize = productFeatures?.baseMaxDataSizePerDocument;
const rowRatio = getUsageRatio(rowCount, maxRows); const rowRatio = getUsageRatio(rowCount?.total, maxRows);
const dataSizeRatio = getUsageRatio(dataSizeBytes, maxDataSize); const dataSizeRatio = getUsageRatio(dataSizeBytes, maxDataSize);
return Math.max(rowRatio, dataSizeRatio); return Math.max(rowRatio, dataSizeRatio);
} }

View File

@ -1,9 +1,14 @@
export interface DocumentUsage { export interface DocumentUsage {
rowCount?: number; rowCount?: RowCounts;
dataSizeBytes?: number; dataSizeBytes?: number;
attachmentsSizeBytes?: number; attachmentsSizeBytes?: number;
} }
export interface RowCounts {
total: number;
[tableRef: number]: number;
}
export type DataLimitStatus = 'approachingLimit' | 'gracePeriod' | 'deleteOnly' | null; export type DataLimitStatus = 'approachingLimit' | 'gracePeriod' | 'deleteOnly' | null;
type DocUsageOrPending = { type DocUsageOrPending = {

View File

@ -51,6 +51,7 @@ import {
DocUsageSummary, DocUsageSummary,
FilteredDocUsageSummary, FilteredDocUsageSummary,
getUsageRatio, getUsageRatio,
RowCounts,
} from 'app/common/DocUsage'; } from 'app/common/DocUsage';
import {normalizeEmail} from 'app/common/emails'; import {normalizeEmail} from 'app/common/emails';
import {ErrorWithCode} from 'app/common/ErrorWithCode'; import {ErrorWithCode} from 'app/common/ErrorWithCode';
@ -320,7 +321,7 @@ export class ActiveDoc extends EventEmitter {
public get rowLimitRatio(): number { public get rowLimitRatio(): number {
return getUsageRatio( return getUsageRatio(
this._docUsage?.rowCount, this._docUsage?.rowCount?.total,
this._product?.features.baseMaxRowsPerDocument this._product?.features.baseMaxRowsPerDocument
); );
} }
@ -1666,10 +1667,10 @@ export class ActiveDoc extends EventEmitter {
return this._granularAccess; return this._granularAccess;
} }
public async updateRowCount(rowCount: number, docSession: OptDocSession | null) { public async updateRowCount(rowCount: RowCounts, docSession: OptDocSession | null) {
// Up-to-date row counts are included in every DocUserAction, so we can skip broadcasting here. // Up-to-date row counts are included in every DocUserAction, so we can skip broadcasting here.
await this._updateDocUsage({rowCount}, {broadcastUsageToClients: false}); await this._updateDocUsage({rowCount}, {broadcastUsageToClients: false});
log.rawInfo('Sandbox row count', {...this.getLogMeta(docSession), rowCount}); log.rawInfo('Sandbox row count', {...this.getLogMeta(docSession), rowCount: rowCount.total});
await this._checkDataLimitRatio(); await this._checkDataLimitRatio();
// Calculating data size is potentially expensive, so skip calculating it unless the // Calculating data size is potentially expensive, so skip calculating it unless the
@ -2269,7 +2270,7 @@ function createEmptySandboxActionBundle(): SandboxActionBundle {
calc: [], calc: [],
undo: [], undo: [],
retValues: [], retValues: [],
rowCount: 0, rowCount: {total: 0},
}; };
} }

View File

@ -1262,11 +1262,13 @@ class Engine(object):
self._unused_lookups.add(lookup_map_column) self._unused_lookups.add(lookup_map_column)
def count_rows(self): def count_rows(self):
return sum( result = {"total": 0}
table._num_rows() for table_rec in self.docmodel.tables.all:
for table_id, table in six.iteritems(self.tables) if useractions.is_user_table(table_rec.tableId):
if useractions.is_user_table(table_id) and not table._summary_source_table count = self.tables[table_rec.tableId]._num_rows()
) result[table_rec.id] = count
result["total"] += count
return result
def apply_user_actions(self, user_actions, user=None): def apply_user_actions(self, user_actions, user=None):
""" """

View File

@ -1266,7 +1266,7 @@ class TestUserActions(test_engine.EngineTestCase):
for i in range(20): for i in range(20):
self.add_record("Address", None) self.add_record("Address", None)
self.assertEqual(i + 1, table._num_rows()) self.assertEqual(i + 1, table._num_rows())
self.assertEqual(i + 1, self.engine.count_rows()) self.assertEqual({1: i + 1, "total": i + 1}, self.engine.count_rows())
def test_raw_view_section_restrictions(self): def test_raw_view_section_restrictions(self):
# load_sample handles loading basic metadata, but doesn't create any view sections # load_sample handles loading basic metadata, but doesn't create any view sections