mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
40c9b8b7e8
commit
771e1edd54
@ -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;
|
||||||
`);
|
`);
|
||||||
|
@ -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,
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 = {
|
||||||
|
@ -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},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user