(core) Polish display of table row counts

Summary:
Adds a new dots loader, which is used on the Raw Data page when
certain values are still being calculated (e.g. row counts). Now metrics
whose values aren't known yet will still appear under Usage, but with a
"Loading" message and the dots loader shown. For per-table row counts,
we only show the loader.

Test Plan: Existing tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3566
This commit is contained in:
George Gevoian 2022-08-11 08:09:03 -07:00
parent b416a5c4b1
commit 3ad78590c2
3 changed files with 127 additions and 57 deletions

View File

@ -7,6 +7,7 @@ import {showTransientTooltip} from 'app/client/ui/tooltips';
import {buildTableName} from 'app/client/ui/WidgetTitle'; import {buildTableName} from 'app/client/ui/WidgetTitle';
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 {loadingDots} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
@ -22,6 +23,9 @@ export class DataTables extends Disposable {
} }
); );
// TODO: Update this whenever the rest of the UI is internationalized.
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
constructor(private _gristDoc: GristDoc) { constructor(private _gristDoc: GristDoc) {
super(); super();
this._tables = Computed.create(this, use => { this._tables = Computed.create(this, use => {
@ -139,18 +143,19 @@ export class DataTables extends Disposable {
} }
private _tableRows(table: TableRec) { private _tableRows(table: TableRec) {
return dom.maybe(this._rowCount, (rowCounts) => {
if (rowCounts === 'hidden') { return null; }
return cssTableRowsWrapper( return cssTableRowsWrapper(
cssUpperCase("Rows: "), cssUpperCase("Rows: "),
cssTableRows( rowCounts === 'pending' ? cssLoadingDots() : cssTableRows(
rowCounts[table.getRowId()] !== undefined
? this._rowCountFormatter.format(rowCounts[table.getRowId()])
: '',
testId('table-rows'), testId('table-rows'),
dom.text(use => { )
const rowCounts = use(this._rowCount);
if (typeof rowCounts !== 'object') { return ''; }
return rowCounts[table.getRowId()]?.toString() ?? '';
}),
),
); );
});
} }
} }
@ -285,3 +290,7 @@ const cssTableList = styled('div', `
position: relative; position: relative;
margin-bottom: 56px; margin-bottom: 56px;
`); `);
const cssLoadingDots = styled(loadingDots, `
--dot-size: 6px;
`);

View File

@ -4,7 +4,7 @@ import {docListHeader} from 'app/client/ui/DocMenuCss';
import {infoTooltip} from 'app/client/ui/tooltips'; import {infoTooltip} from 'app/client/ui/tooltips';
import {colors, mediaXSmall} from 'app/client/ui2018/cssVars'; import {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage'; import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
import {Features, isFreePlan} from 'app/common/Features'; import {Features, isFreePlan} from 'app/common/Features';
import {capitalizeFirstWord} from 'app/common/gutil'; import {capitalizeFirstWord} from 'app/common/gutil';
@ -34,6 +34,9 @@ export class DocumentUsage extends Disposable {
private readonly _currentOrg = this._docPageModel.currentOrg; private readonly _currentOrg = this._docPageModel.currentOrg;
private readonly _currentProduct = this._docPageModel.currentProduct; private readonly _currentProduct = this._docPageModel.currentProduct;
// TODO: Update this whenever the rest of the UI is internationalized.
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => { private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => {
return usage?.dataLimitStatus ?? null; return usage?.dataLimitStatus ?? null;
}); });
@ -50,34 +53,29 @@ export class DocumentUsage extends Disposable {
return usage?.attachmentsSizeBytes; return usage?.attachmentsSizeBytes;
}); });
private readonly _rowMetrics: Computed<MetricOptions | null> = private readonly _rowMetricOptions: Computed<MetricOptions> =
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 maxRows = product?.features.baseMaxRowsPerDocument;
if (!features || typeof rowCount !== 'object') { return null; }
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.total, currentValue: typeof rowCount !== 'object' ? undefined : rowCount.total,
maximumValue: maxValue ?? DEFAULT_MAX_ROWS, maximumValue: maxValue ?? DEFAULT_MAX_ROWS,
unit: 'rows', unit: 'rows',
shouldHideLimits: maxValue === undefined, shouldHideLimits: maxValue === undefined,
formatValue: (val) => this._rowCountFormatter.format(val),
}; };
}); });
private readonly _dataSizeMetrics: Computed<MetricOptions | null> = private readonly _dataSizeMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._dataSizeBytes, (_use, product, dataSize) => { Computed.create(this, this._currentProduct, this._dataSizeBytes, (_use, product, dataSize) => {
const features = product?.features; const maxSize = product?.features.baseMaxDataSizePerDocument;
if (!features || typeof dataSize !== 'number') { return null; }
const {baseMaxDataSizePerDocument: maxSize} = features;
// Invalid data size limits are currently treated as if they are undefined. // Invalid data size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined; const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return { return {
name: 'Data Size', name: 'Data Size',
currentValue: dataSize, currentValue: typeof dataSize !== 'number' ? undefined : dataSize,
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE, maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
unit: 'MB', unit: 'MB',
shouldHideLimits: maxValue === undefined, shouldHideLimits: maxValue === undefined,
@ -95,17 +93,14 @@ export class DocumentUsage extends Disposable {
}; };
}); });
private readonly _attachmentsSizeMetrics: Computed<MetricOptions | null> = private readonly _attachmentsSizeMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._attachmentsSizeBytes, (_use, product, attachmentsSize) => { Computed.create(this, this._currentProduct, this._attachmentsSizeBytes, (_use, product, attachmentsSize) => {
const features = product?.features; const maxSize = product?.features.baseMaxAttachmentsBytesPerDocument;
if (!features || typeof attachmentsSize !== 'number') { return null; }
const {baseMaxAttachmentsBytesPerDocument: maxSize} = features;
// Invalid attachments size limits are currently treated as if they are undefined. // Invalid attachments size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined; const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return { return {
name: 'Attachments Size', name: 'Attachments Size',
currentValue: attachmentsSize, currentValue: typeof attachmentsSize !== 'number' ? undefined : attachmentsSize,
maximumValue: maxValue ?? DEFAULT_MAX_ATTACHMENTS_SIZE, maximumValue: maxValue ?? DEFAULT_MAX_ATTACHMENTS_SIZE,
unit: 'GB', unit: 'GB',
shouldHideLimits: maxValue === undefined, shouldHideLimits: maxValue === undefined,
@ -113,25 +108,26 @@ export class DocumentUsage extends Disposable {
}; };
}); });
private readonly _isLoading: Computed<boolean> = private readonly _areAllMetricsPending: Computed<boolean> =
Computed.create( Computed.create(
this, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes, this, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, doc, rowCount, dataSize, attachmentsSize) => { (_use, doc, rowCount, dataSize, attachmentsSize) => {
return !doc || [rowCount, dataSize, attachmentsSize].some(metric => { const hasNonPendingMetrics = [rowCount, dataSize, attachmentsSize]
return metric === 'pending' || metric === undefined; .some(metric => metric !== 'pending' && metric !== undefined);
}); return !doc || !hasNonPendingMetrics;
} }
); );
private readonly _isAccessDenied: Computed<boolean | null> = private readonly _isAccessDenied: Computed<boolean | null> =
Computed.create( Computed.create(this, this._areAllMetricsPending, this._currentDoc, this._rowCount,
this, this._isLoading, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes, this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, isLoading, doc, rowCount, dataSize, attachmentsSize) => { (_use, isLoading, doc, rowCount, dataSize, attachmentsSize) => {
if (isLoading) { return null; } if (isLoading) { return null; }
const {access} = doc!.workspace.org; const {access} = doc!.workspace.org;
const isPublicUser = access === 'guests' || access === null; const isPublicUser = access === 'guests' || access === null;
return isPublicUser || [rowCount, dataSize, attachmentsSize].some(metric => metric === 'hidden'); const hasHiddenMetrics = [rowCount, dataSize, attachmentsSize].some(metric => metric === 'hidden');
return isPublicUser || hasHiddenMetrics;
} }
); );
@ -142,7 +138,7 @@ export class DocumentUsage extends Disposable {
public buildDom() { public buildDom() {
return dom('div', return dom('div',
cssHeader('Usage', testId('heading')), cssHeader('Usage', testId('heading')),
dom.domComputed(this._isLoading, (isLoading) => { dom.domComputed(this._areAllMetricsPending, (isLoading) => {
if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); } if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); }
return [this._buildMessage(), this._buildMetrics()]; return [this._buildMessage(), this._buildMetrics()];
@ -181,13 +177,13 @@ export class DocumentUsage extends Disposable {
private _buildMetrics() { private _buildMetrics() {
return dom.maybe(use => use(this._isAccessDenied) === false, () => return dom.maybe(use => use(this._isAccessDenied) === false, () =>
cssUsageMetrics( cssUsageMetrics(
dom.maybe(this._rowMetrics, (metrics) => dom.domComputed(this._rowMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('rows')), buildUsageMetric(metrics, testId('rows')),
), ),
dom.maybe(this._dataSizeMetrics, (metrics) => dom.domComputed(this._dataSizeMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('data-size')), buildUsageMetric(metrics, testId('data-size')),
), ),
dom.maybe(this._attachmentsSizeMetrics, (metrics) => dom.domComputed(this._attachmentsSizeMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('attachments-size')), buildUsageMetric(metrics, testId('attachments-size')),
), ),
testId('metrics'), testId('metrics'),
@ -265,7 +261,8 @@ function buildRawDataPageLink(linkText: string) {
interface MetricOptions { interface MetricOptions {
name: string; name: string;
currentValue: number; // If undefined, loading dots will be shown.
currentValue?: number;
// If undefined or non-positive (i.e. invalid), no limits will be assumed. // If undefined or non-positive (i.e. invalid), no limits will be assumed.
maximumValue?: number; maximumValue?: number;
unit?: string; unit?: string;
@ -282,22 +279,37 @@ interface MetricOptions {
* close `currentValue` is to hitting `maximumValue`. * close `currentValue` is to hitting `maximumValue`.
*/ */
function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) { function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
const { const {name, tooltipContent} = options;
name,
currentValue,
maximumValue,
unit,
shouldHideLimits,
tooltipContent,
formatValue = (val) => val.toString(),
} = options;
const ratioUsed = currentValue / (maximumValue || Infinity);
const percentUsed = Math.min(100, Math.floor(ratioUsed * 100));
return cssUsageMetric( return cssUsageMetric(
cssMetricName( cssMetricName(
cssOverflowableText(name, testId('name')), cssOverflowableText(name, testId('name')),
tooltipContent ? infoTooltip(tooltipContent()) : null, tooltipContent ? infoTooltip(tooltipContent()) : null,
), ),
buildUsageProgressBar(options),
...domArgs,
);
}
function buildUsageProgressBar(options: MetricOptions) {
const {
currentValue,
maximumValue,
shouldHideLimits,
unit,
formatValue = (n) => n.toString()
} = options;
let ratioUsed: number;
let percentUsed: number;
if (currentValue === undefined) {
ratioUsed = 0;
percentUsed = 0;
} else {
ratioUsed = currentValue / (maximumValue || Infinity);
percentUsed = Math.min(100, Math.floor(ratioUsed * 100));
}
return [
cssProgressBarContainer( cssProgressBarContainer(
cssProgressBarFill( cssProgressBarFill(
{style: `width: ${percentUsed}%`}, {style: `width: ${percentUsed}%`},
@ -309,13 +321,12 @@ function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
), ),
), ),
dom('div', dom('div',
formatValue(currentValue) currentValue === undefined ? ['Loading ', cssLoadingDots()] : formatValue(currentValue)
+ (shouldHideLimits || !maximumValue ? '' : ' of ' + formatValue(maximumValue)) + (shouldHideLimits || !maximumValue ? '' : ' of ' + formatValue(maximumValue))
+ (unit ? ` ${unit}` : ''), + (unit ? ` ${unit}` : ''),
testId('value'), testId('value'),
), ),
...domArgs, ];
);
} }
function buildMessage(message: DomContents) { function buildMessage(message: DomContents) {
@ -419,3 +430,7 @@ const cssTooltipBody = styled('div', `
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
`); `);
const cssLoadingDots = styled(loadingDots, `
--dot-size: 8px;
`);

View File

@ -1,5 +1,5 @@
import {colors} from 'app/client/ui2018/cssVars'; import {colors} from 'app/client/ui2018/cssVars';
import {keyframes, styled} from 'grainjs'; import {DomArg, keyframes, styled} from 'grainjs';
const rotate360 = keyframes(` const rotate360 = keyframes(`
from { transform: rotate(45deg); } from { transform: rotate(45deg); }
@ -7,6 +7,15 @@ const rotate360 = keyframes(`
to { transform: rotate(405deg); } to { transform: rotate(405deg); }
`); `);
const flash = keyframes(`
0% {
background-color: ${colors.lightGreen};
}
50%, 100% {
background-color: ${colors.darkGrey};
}
`);
/** /**
* Creates a 32x32 pixel loading spinner. Use by calling `loadingSpinner()`. * Creates a 32x32 pixel loading spinner. Use by calling `loadingSpinner()`.
*/ */
@ -20,3 +29,40 @@ export const loadingSpinner = styled('div', `
border-top-color: ${colors.lightGreen}; border-top-color: ${colors.lightGreen};
animation: ${rotate360} 1s ease-out infinite; animation: ${rotate360} 1s ease-out infinite;
`); `);
/**
* Creates a three-dots loading animation. Use by calling `loadingDots()`.
*/
export function loadingDots(...args: DomArg<HTMLDivElement>[]) {
return cssLoadingDotsContainer(
cssLoadingDot(cssLoadingDot.cls('-left')),
cssLoadingDot(cssLoadingDot.cls('-middle')),
cssLoadingDot(cssLoadingDot.cls('-right')),
...args,
);
}
const cssLoadingDotsContainer = styled('div', `
--dot-size: 10px;
display: inline-flex;
column-gap: calc(var(--dot-size) / 2);
`);
const cssLoadingDot = styled('div', `
border-radius: 50%;
width: var(--dot-size);
height: var(--dot-size);
background-color: ${colors.lightGreen};
color: ${colors.lightGreen};
animation: ${flash} 1s alternate infinite;
&-left {
animation-delay: 0s;
}
&-middle {
animation-delay: 0.25s;
}
&-right {
animation-delay: 0.5s;
}
`);