From 3ad78590c22baaf86343219038a4cbc837196258 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Thu, 11 Aug 2022 08:09:03 -0700 Subject: [PATCH] (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 --- app/client/components/DataTables.ts | 31 +++++--- app/client/components/DocumentUsage.ts | 105 ++++++++++++++----------- app/client/ui2018/loaders.ts | 48 ++++++++++- 3 files changed, 127 insertions(+), 57 deletions(-) diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index f485263f..26bf07bd 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -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; +`); diff --git a/app/client/components/DocumentUsage.ts b/app/client/components/DocumentUsage.ts index 6c35cac7..4e1140d0 100644 --- a/app/client/components/DocumentUsage.ts +++ b/app/client/components/DocumentUsage.ts @@ -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 = + private readonly _rowMetricOptions: Computed = 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 = + private readonly _dataSizeMetricOptions: Computed = 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 = + private readonly _attachmentsSizeMetricOptions: Computed = 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 = + private readonly _areAllMetricsPending: Computed = 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 = - 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; +`); diff --git a/app/client/ui2018/loaders.ts b/app/client/ui2018/loaders.ts index 72f5a30a..da18454a 100644 --- a/app/client/ui2018/loaders.ts +++ b/app/client/ui2018/loaders.ts @@ -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[]) { + 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; + } +`);