(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 * as css from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {loadingDots} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
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) {
super();
this._tables = Computed.create(this, use => {
@ -139,18 +143,19 @@ export class DataTables extends Disposable {
}
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 dom.maybe(this._rowCount, (rowCounts) => {
if (rowCounts === 'hidden') { return null; }
return rowCounts[table.getRowId()]?.toString() ?? '';
}),
),
);
return cssTableRowsWrapper(
cssUpperCase("Rows: "),
rowCounts === 'pending' ? cssLoadingDots() : cssTableRows(
rowCounts[table.getRowId()] !== undefined
? this._rowCountFormatter.format(rowCounts[table.getRowId()])
: '',
testId('table-rows'),
)
);
});
}
}
@ -285,3 +290,7 @@ const cssTableList = styled('div', `
position: relative;
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 {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
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 {Features, isFreePlan} from 'app/common/Features';
import {capitalizeFirstWord} from 'app/common/gutil';
@ -34,6 +34,9 @@ export class DocumentUsage extends Disposable {
private readonly _currentOrg = this._docPageModel.currentOrg;
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) => {
return usage?.dataLimitStatus ?? null;
});
@ -50,34 +53,29 @@ export class DocumentUsage extends Disposable {
return usage?.attachmentsSizeBytes;
});
private readonly _rowMetrics: Computed<MetricOptions | null> =
private readonly _rowMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => {
const features = product?.features;
if (!features || typeof rowCount !== 'object') { return null; }
const {baseMaxRowsPerDocument: maxRows} = features;
const maxRows = product?.features.baseMaxRowsPerDocument;
// Invalid row limits are currently treated as if they are undefined.
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
return {
name: 'Rows',
currentValue: rowCount.total,
currentValue: typeof rowCount !== 'object' ? undefined : rowCount.total,
maximumValue: maxValue ?? DEFAULT_MAX_ROWS,
unit: 'rows',
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) => {
const features = product?.features;
if (!features || typeof dataSize !== 'number') { return null; }
const {baseMaxDataSizePerDocument: maxSize} = features;
const maxSize = product?.features.baseMaxDataSizePerDocument;
// Invalid data size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: 'Data Size',
currentValue: dataSize,
currentValue: typeof dataSize !== 'number' ? undefined : dataSize,
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
unit: 'MB',
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) => {
const features = product?.features;
if (!features || typeof attachmentsSize !== 'number') { return null; }
const {baseMaxAttachmentsBytesPerDocument: maxSize} = features;
const maxSize = product?.features.baseMaxAttachmentsBytesPerDocument;
// Invalid attachments size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: 'Attachments Size',
currentValue: attachmentsSize,
currentValue: typeof attachmentsSize !== 'number' ? undefined : attachmentsSize,
maximumValue: maxValue ?? DEFAULT_MAX_ATTACHMENTS_SIZE,
unit: 'GB',
shouldHideLimits: maxValue === undefined,
@ -113,25 +108,26 @@ export class DocumentUsage extends Disposable {
};
});
private readonly _isLoading: Computed<boolean> =
private readonly _areAllMetricsPending: Computed<boolean> =
Computed.create(
this, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, doc, rowCount, dataSize, attachmentsSize) => {
return !doc || [rowCount, dataSize, attachmentsSize].some(metric => {
return metric === 'pending' || metric === undefined;
});
const hasNonPendingMetrics = [rowCount, dataSize, attachmentsSize]
.some(metric => metric !== 'pending' && metric !== undefined);
return !doc || !hasNonPendingMetrics;
}
);
private readonly _isAccessDenied: Computed<boolean | null> =
Computed.create(
this, this._isLoading, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes,
Computed.create(this, this._areAllMetricsPending, this._currentDoc, this._rowCount,
this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, isLoading, doc, rowCount, dataSize, attachmentsSize) => {
if (isLoading) { return null; }
const {access} = doc!.workspace.org;
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() {
return dom('div',
cssHeader('Usage', testId('heading')),
dom.domComputed(this._isLoading, (isLoading) => {
dom.domComputed(this._areAllMetricsPending, (isLoading) => {
if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); }
return [this._buildMessage(), this._buildMetrics()];
@ -181,13 +177,13 @@ export class DocumentUsage extends Disposable {
private _buildMetrics() {
return dom.maybe(use => use(this._isAccessDenied) === false, () =>
cssUsageMetrics(
dom.maybe(this._rowMetrics, (metrics) =>
dom.domComputed(this._rowMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('rows')),
),
dom.maybe(this._dataSizeMetrics, (metrics) =>
dom.domComputed(this._dataSizeMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('data-size')),
),
dom.maybe(this._attachmentsSizeMetrics, (metrics) =>
dom.domComputed(this._attachmentsSizeMetricOptions, (metrics) =>
buildUsageMetric(metrics, testId('attachments-size')),
),
testId('metrics'),
@ -265,7 +261,8 @@ function buildRawDataPageLink(linkText: string) {
interface MetricOptions {
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.
maximumValue?: number;
unit?: string;
@ -282,22 +279,37 @@ interface MetricOptions {
* close `currentValue` is to hitting `maximumValue`.
*/
function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
const {
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));
const {name, tooltipContent} = options;
return cssUsageMetric(
cssMetricName(
cssOverflowableText(name, testId('name')),
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(
cssProgressBarFill(
{style: `width: ${percentUsed}%`},
@ -309,13 +321,12 @@ function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
),
),
dom('div',
formatValue(currentValue)
currentValue === undefined ? ['Loading ', cssLoadingDots()] : formatValue(currentValue)
+ (shouldHideLimits || !maximumValue ? '' : ' of ' + formatValue(maximumValue))
+ (unit ? ` ${unit}` : ''),
testId('value'),
),
...domArgs,
);
];
}
function buildMessage(message: DomContents) {
@ -419,3 +430,7 @@ const cssTooltipBody = styled('div', `
flex-direction: column;
gap: 8px;
`);
const cssLoadingDots = styled(loadingDots, `
--dot-size: 8px;
`);

View File

@ -1,5 +1,5 @@
import {colors} from 'app/client/ui2018/cssVars';
import {keyframes, styled} from 'grainjs';
import {DomArg, keyframes, styled} from 'grainjs';
const rotate360 = keyframes(`
from { transform: rotate(45deg); }
@ -7,6 +7,15 @@ const rotate360 = keyframes(`
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()`.
*/
@ -20,3 +29,40 @@ export const loadingSpinner = styled('div', `
border-top-color: ${colors.lightGreen};
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;
}
`);