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; + } +`);