diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index 6cc2a5f0..f485263f 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -1,12 +1,10 @@ import {GristDoc} from 'app/client/components/GristDoc'; import {copyToClipboard} from 'app/client/lib/copyToClipboard'; -import {localStorageObs} from 'app/client/lib/localStorageObs'; import {setTestState} from 'app/client/lib/testState'; import {TableRec} from 'app/client/models/DocModel'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; import {showTransientTooltip} from 'app/client/ui/tooltips'; import {buildTableName} from 'app/client/ui/WidgetTitle'; -import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect'; import * as css from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; @@ -17,7 +15,13 @@ const testId = makeTestId('test-raw-data-'); export class DataTables extends Disposable { private _tables: Observable; - private _view: Observable; + + private readonly _rowCount = Computed.create( + this, this._gristDoc.docPageModel.currentDocUsage, (_use, usage) => { + return usage?.rowCount; + } + ); + constructor(private _gristDoc: GristDoc) { super(); 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. 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() { @@ -37,22 +37,8 @@ export class DataTables extends Disposable { cssTableList( /*************** List section **********/ testId('list'), - cssBetween( - docListHeader('Raw Data Tables'), - cssSwitch( - buttonSelect( - this._view, - [ - {value: 'card', icon: 'TypeTable'}, - {value: 'list', icon: 'TypeCardList'}, - ], - css.testId('view-mode'), - cssButtonSelect.cls("-light") - ) - ) - ), + docListHeader('Raw Data Tables'), cssList( - cssList.cls(use => `-${use(this._view)}`), dom.forEach(this._tables, tableRec => cssItem( testId('table'), @@ -63,10 +49,10 @@ export class DataTables extends Disposable { )), ), cssMiddle( - css60(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))), - css40( - cssIdHoverWrapper( - cssUpperCase("Table id: "), + cssTitleRow(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))), + cssDetailsRow( + cssTableIdWrapper(cssHoverWrapper( + cssUpperCase("Table ID: "), cssTableId( testId('table-id'), dom.text(tableRec.tableId), @@ -75,13 +61,14 @@ export class DataTables extends Disposable { dom.on('click', async (e, t) => { e.stopImmediatePropagation(); e.preventDefault(); - showTransientTooltip(t, 'Table id copied to clipboard', { + showTransientTooltip(t, 'Table ID copied to clipboard', { key: 'copy-table-id' }); await copyToClipboard(tableRec.tableId.peek()); setTestState({clipboard: tableRec.tableId.peek()}); }) - ) + )), + this._tableRows(tableRec), ), ), cssRight( @@ -150,6 +137,21 @@ export class DataTables extends Disposable { } 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', ` @@ -157,38 +159,10 @@ const container = styled('div', ` 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', ` display: flex; - &-list { - flex-direction: column; - gap: 8px; - } - &-card { - flex-direction: row; - flex-wrap: wrap; - gap: 24px; - } - @media ${css.mediaSmall} { - & { - gap: 12px !important; - } - } + flex-direction: column; + gap: 12px; `); const cssItem = styled('div', ` @@ -196,29 +170,13 @@ const cssItem = styled('div', ` align-items: center; cursor: pointer; border-radius: 3px; + width: 100%; + height: calc(1em * 56/13); /* 56px for 13px font */ max-width: 750px; border: 1px solid ${css.colors.mediumGrey}; &:hover { 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 @@ -238,21 +196,17 @@ const cssMiddle = styled('div', ` flex-wrap: wrap; margin-top: 6px; margin-bottom: 4px; - .${cssList.className}-card & { - margin: 0px; - } `); -const css60 = styled('div', ` - min-width: min(240px, 100%); - flex: 6; +const cssTitleRow = styled('div', ` + min-width: 100%; margin-right: 4px; `); -const css40 = styled('div', ` - min-width: min(240px, 100%); - flex: 4; +const cssDetailsRow = styled('div', ` + min-width: 100%; display: flex; + gap: 8px; `); @@ -275,29 +229,43 @@ const cssLine = styled('span', ` 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; overflow: hidden; cursor: default; align-items: baseline; color: ${css.colors.slate}; transition: background 0.05s; - padding: 1px 2px; + padding: 0px 2px; line-height: 18px; &:hover { background: ${css.colors.lightGrey}; } - @media ${css.mediaSmall} { - & { - padding: 0px 2px !important; - } - } `); const cssTableId = styled(cssLine, ` font-size: ${css.vars.smallFontSize}; `); +const cssTableRows = cssTableId; + const cssTableTitle = styled('div', ` white-space: nowrap; `); diff --git a/app/client/components/DocumentUsage.ts b/app/client/components/DocumentUsage.ts index f7aa7974..6c35cac7 100644 --- a/app/client/components/DocumentUsage.ts +++ b/app/client/components/DocumentUsage.ts @@ -53,14 +53,14 @@ export class DocumentUsage extends Disposable { private readonly _rowMetrics: Computed = Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => { const features = product?.features; - if (!features || typeof rowCount !== 'number') { return null; } + if (!features || typeof rowCount !== 'object') { return null; } const {baseMaxRowsPerDocument: maxRows} = features; // Invalid row limits are currently treated as if they are undefined. const maxValue = maxRows && maxRows > 0 ? maxRows : undefined; return { name: 'Rows', - currentValue: rowCount, + currentValue: rowCount.total, maximumValue: maxValue ?? DEFAULT_MAX_ROWS, unit: 'rows', shouldHideLimits: maxValue === undefined, diff --git a/app/common/ActionBundle.ts b/app/common/ActionBundle.ts index f98f70b1..336b8aa7 100644 --- a/app/common/ActionBundle.ts +++ b/app/common/ActionBundle.ts @@ -4,6 +4,7 @@ */ import {DocAction, UserAction} from 'app/common/DocActions'; +import {RowCounts} from 'app/common/DocUsage'; // Metadata about the action. export interface ActionInfo { @@ -61,7 +62,7 @@ export interface SandboxActionBundle { calc: Array>; undo: Array>; // Inverse actions for all 'stored' actions. 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 requests?: Record; } diff --git a/app/common/DocLimits.ts b/app/common/DocLimits.ts index 4471d619..dae8dfed 100644 --- a/app/common/DocLimits.ts +++ b/app/common/DocLimits.ts @@ -53,7 +53,7 @@ export function getDataLimitRatio( const {rowCount, dataSizeBytes} = docUsage; const maxRows = productFeatures?.baseMaxRowsPerDocument; const maxDataSize = productFeatures?.baseMaxDataSizePerDocument; - const rowRatio = getUsageRatio(rowCount, maxRows); + const rowRatio = getUsageRatio(rowCount?.total, maxRows); const dataSizeRatio = getUsageRatio(dataSizeBytes, maxDataSize); return Math.max(rowRatio, dataSizeRatio); } diff --git a/app/common/DocUsage.ts b/app/common/DocUsage.ts index afe842bc..0ecb0c84 100644 --- a/app/common/DocUsage.ts +++ b/app/common/DocUsage.ts @@ -1,9 +1,14 @@ export interface DocumentUsage { - rowCount?: number; + rowCount?: RowCounts; dataSizeBytes?: number; attachmentsSizeBytes?: number; } +export interface RowCounts { + total: number; + [tableRef: number]: number; +} + export type DataLimitStatus = 'approachingLimit' | 'gracePeriod' | 'deleteOnly' | null; type DocUsageOrPending = { diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 05ed380b..7b115d24 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -51,6 +51,7 @@ import { DocUsageSummary, FilteredDocUsageSummary, getUsageRatio, + RowCounts, } from 'app/common/DocUsage'; import {normalizeEmail} from 'app/common/emails'; import {ErrorWithCode} from 'app/common/ErrorWithCode'; @@ -320,7 +321,7 @@ export class ActiveDoc extends EventEmitter { public get rowLimitRatio(): number { return getUsageRatio( - this._docUsage?.rowCount, + this._docUsage?.rowCount?.total, this._product?.features.baseMaxRowsPerDocument ); } @@ -1666,10 +1667,10 @@ export class ActiveDoc extends EventEmitter { 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. 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(); // Calculating data size is potentially expensive, so skip calculating it unless the @@ -2269,7 +2270,7 @@ function createEmptySandboxActionBundle(): SandboxActionBundle { calc: [], undo: [], retValues: [], - rowCount: 0, + rowCount: {total: 0}, }; } diff --git a/sandbox/grist/engine.py b/sandbox/grist/engine.py index 92a92fbf..d968e94a 100644 --- a/sandbox/grist/engine.py +++ b/sandbox/grist/engine.py @@ -1262,11 +1262,13 @@ class Engine(object): self._unused_lookups.add(lookup_map_column) def count_rows(self): - return sum( - table._num_rows() - for table_id, table in six.iteritems(self.tables) - if useractions.is_user_table(table_id) and not table._summary_source_table - ) + result = {"total": 0} + for table_rec in self.docmodel.tables.all: + if useractions.is_user_table(table_rec.tableId): + 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): """ diff --git a/sandbox/grist/test_useractions.py b/sandbox/grist/test_useractions.py index 631c93ed..f7511c38 100644 --- a/sandbox/grist/test_useractions.py +++ b/sandbox/grist/test_useractions.py @@ -1266,7 +1266,7 @@ class TestUserActions(test_engine.EngineTestCase): for i in range(20): self.add_record("Address", None) 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): # load_sample handles loading basic metadata, but doesn't create any view sections