From d8af25de9dbc843bc257d75d6628a97455ff7c1b Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Fri, 1 Apr 2022 11:17:06 -0700 Subject: [PATCH] (core) Add usage to data tables page Summary: Currently, usage is only shown for free team sites, and only for total number of rows used in a document. Future diffs will include other usage metrics and browser tests. Test Plan: Planned for future diffs; UI is still under development. Reviewers: jarek Reviewed By: jarek Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3343 --- app/client/components/DataTables.ts | 116 ++---------------- app/client/components/DocumentUsage.ts | 163 +++++++++++++++++++++++++ app/client/components/GristDoc.ts | 4 +- app/client/components/RawData.ts | 136 +++++++++++++++++++++ app/client/ui/Tools.ts | 8 -- 5 files changed, 309 insertions(+), 118 deletions(-) create mode 100644 app/client/components/DocumentUsage.ts create mode 100644 app/client/components/RawData.ts diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index ca6fe188..80a2d6ae 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -1,33 +1,22 @@ -import * as commands from 'app/client/components/commands'; import {GristDoc} from 'app/client/components/GristDoc'; -import {printViewSection} from 'app/client/components/Printing'; -import {buildViewSectionDom, ViewSectionHelper} from 'app/client/components/ViewLayout'; import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {localStorageObs} from 'app/client/lib/localStorageObs'; import {setTestState} from 'app/client/lib/testState'; import {TableRec} from 'app/client/models/DocModel'; -import {reportError} from 'app/client/models/errors'; -import {docList, docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; +import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; import {showTransientTooltip} from 'app/client/ui/tooltips'; import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect'; import * as css from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; -import {Computed, Disposable, dom, fromKo, makeTestId, MultiHolder, styled} from 'grainjs'; +import {Disposable, dom, fromKo, makeTestId, MultiHolder, styled} from 'grainjs'; const testId = makeTestId('test-raw-data-'); export class DataTables extends Disposable { - private _popupVisible = Computed.create(this, use => Boolean(use(this._gristDoc.viewModel.activeSectionId))); - constructor(private _gristDoc: GristDoc) { super(); - const commandGroup = { - cancel: () => { this._close(); }, - printSection: () => { printViewSection(null, this._gristDoc.viewModel.activeSection()).catch(reportError); }, - }; - this.autoDispose(commands.createGroup(commandGroup, this, true)); } public buildDom() { @@ -35,11 +24,9 @@ export class DataTables extends Disposable { // Get the user id, to remember selected layout on the next visit. const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0; const view = holder.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list")); - // Handler to close the lightbox. - const close = this._close.bind(this); return container( dom.autoDispose(holder), - docList( + cssTableList( /*************** List section **********/ testId('list'), cssBetween( @@ -108,39 +95,9 @@ export class DataTables extends Disposable { ) ), ), - /*************** Lightbox section **********/ - container.cls("-lightbox", this._popupVisible), - dom.domComputedOwned(fromKo(this._gristDoc.viewModel.activeSection), (owner, viewSection) => { - if (!viewSection.getRowId()) { - return null; - } - ViewSectionHelper.create(owner, this._gristDoc, viewSection); - return cssOverlay( - testId('overlay'), - cssSectionWrapper( - buildViewSectionDom({ - gristDoc: this._gristDoc, - sectionRowId: viewSection.getRowId(), - draggable: false, - focusable: false, - onRename: this._renameSection.bind(this) - }) - ), - cssCloseButton('CrossBig', - testId('close-button'), - dom.on('click', close) - ), - // Close the lightbox when user clicks exactly on the overlay. - dom.on('click', (ev, elem) => void (ev.target === elem ? close() : null)) - ); - }), ); } - private _close() { - this._gristDoc.viewModel.activeSectionId(0); - } - private _menuItems(t: TableRec) { const {isReadonly, docModel} = this._gristDoc; return [ @@ -157,12 +114,6 @@ export class DataTables extends Disposable { ]; } - private async _renameSection(name: string) { - // here we will rename primary page for active primary viewSection - const primaryViewName = this._gristDoc.viewModel.activeSection.peek().table.peek().primaryView.peek().name; - await primaryViewName.saveOnly(name); - } - private _removeTable(t: TableRec) { const {docModel} = this._gristDoc; function doRemove() { @@ -177,9 +128,8 @@ export class DataTables extends Disposable { } const container = styled('div', ` - overflow: hidden; + overflow-y: auto; position: relative; - height: 100%; `); const cssBetween = styled('div', ` @@ -353,58 +303,8 @@ const cssDots = styled('div', ` margin-right: 8px; `); -const cssOverlay = styled('div', ` - z-index: 10; - background-color: ${css.colors.backdrop}; - inset: 0px; - height: 100%; - width: 100%; - padding: 32px 56px 0px 56px; - position: absolute; - @media ${css.mediaSmall} { - & { - padding: 22px; - padding-top: 30px; - } - } -`); - -const cssSectionWrapper = styled('div', ` - background: white; - height: 100%; - display: flex; - flex-direction: column; - overflow: hidden; - border-radius: 5px; - border-bottom-left-radius: 0px; - border-bottom-right-radius: 0px; - & .viewsection_content { - margin: 0px; - margin-top: 12px; - } - & .viewsection_title { - padding: 0px 12px; - } - & .filter_bar { - margin-left: 6px; - } -`); - -const cssCloseButton = styled(icon, ` - position: absolute; - top: 16px; - right: 16px; - height: 24px; - width: 24px; - cursor: pointer; - --icon-color: ${css.vars.primaryBg}; - &:hover { - --icon-color: ${css.colors.lighterGreen}; - } - @media ${css.mediaSmall} { - & { - top: 6px; - right: 6px; - } - } +const cssTableList = styled('div', ` + overflow-y: auto; + position: relative; + margin-bottom: 56px; `); diff --git a/app/client/components/DocumentUsage.ts b/app/client/components/DocumentUsage.ts new file mode 100644 index 00000000..8165bd1e --- /dev/null +++ b/app/client/components/DocumentUsage.ts @@ -0,0 +1,163 @@ +import {DocPageModel} from 'app/client/models/DocPageModel'; +import {docListHeader} from 'app/client/ui/DocMenuCss'; +import {colors, mediaXSmall} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {cssLink} from 'app/client/ui2018/links'; +import {DataLimitStatus} from 'app/common/ActiveDocAPI'; +import {commonUrls} from 'app/common/gristUrls'; +import {Computed, Disposable, dom, IDisposableOwner, Observable, styled} from 'grainjs'; + +const limitStatusMessages: Record, string> = { + approachingLimit: 'This document is approaching free plan limits.', + deleteOnly: 'This document is now in delete-only mode.', + gracePeriod: 'This document has exceeded free plan limits.', +}; + +/** + * Displays statistics about document usage, such as number of rows used. + * + * Currently only shows usage if current site is a free team site. + */ +export class DocumentUsage extends Disposable { + constructor(private _docPageModel: DocPageModel) { + super(); + } + + public buildDom() { + const features = this._docPageModel.appModel.currentFeatures; + if (features.baseMaxRowsPerDocument === undefined) { return null; } + + return dom('div', + cssHeader('Usage'), + dom.domComputed(this._docPageModel.dataLimitStatus, status => { + if (!status) { return null; } + + return cssLimitWarning( + cssIcon('Idea'), + cssLightlyBoldedText( + limitStatusMessages[status], + ' For higher limits, ', + cssUnderlinedLink('start your 30-day free trial of the Pro plan.', { + href: commonUrls.plans, + target: '_blank', + }), + ), + ); + }), + cssUsageMetrics( + dom.create(buildUsageMetric, { + name: 'Rows', + currentValue: this._docPageModel.rowCount, + maximumValue: features.baseMaxRowsPerDocument, + units: 'rows', + }), + ) + ); + } +} + +/** + * Builds a component which displays the current and maximum values for + * a particular metric (e.g. rows), and a progress meter showing how + * close `currentValue` is to hitting `maximumValue`. + */ +function buildUsageMetric(owner: IDisposableOwner, {name, currentValue, maximumValue, units}: { + name: string; + currentValue: Observable; + maximumValue: number; + units?: string; +}) { + const percentUsed = Computed.create(owner, currentValue, (_use, value) => { + return Math.min(100, Math.floor(((value ?? 0) / maximumValue) * 100)); + }); + return cssUsageMetric( + cssMetricName(name), + cssProgressBarContainer( + cssProgressBarFill( + dom.style('width', use => `${use(percentUsed)}%`), + cssProgressBarFill.cls('-approaching-limit', use => use(percentUsed) >= 90) + ) + ), + dom.maybe(currentValue, value => + dom('div', `${value} of ${maximumValue}` + (units ? ` ${units}` : '')) + ), + ); +} + +const cssLightlyBoldedText = styled('div', ` + font-weight: 500; +`); + +const cssIconAndText = styled('div', ` + display: flex; + gap: 16px; +`); + +const cssLimitWarning = styled(cssIconAndText, ` + margin-top: 16px; +`); + +const cssIcon = styled(icon, ` + flex-shrink: 0; + width: 16px; + height: 16px; +`); + +const cssMetricName = styled('div', ` + font-weight: 700; +`); + +const cssHeader = styled(docListHeader, ` + margin-bottom: 0px; +`); + +const cssUnderlinedLink = styled(cssLink, ` + display: inline-block; + color: unset; + text-decoration: underline; + + &:hover, &:focus { + color: unset; + } +`); + +const cssUsageMetrics = styled('div', ` + display: flex; + flex-wrap: wrap; + margin-top: 24px; + gap: 56px; + + @media ${mediaXSmall} { + & { + gap: 24px; + } + } +`); + +const cssUsageMetric = styled('div', ` + display: flex; + flex-direction: column; + width: 180px; + gap: 8px; + + @media ${mediaXSmall} { + & { + width: 100%; + } + } +`); + +const cssProgressBarContainer = styled('div', ` + width: 100%; + height: 4px; + border-radius: 5px; + background: ${colors.darkGrey}; +`); + +const cssProgressBarFill = styled(cssProgressBarContainer, ` + background: ${colors.lightGreen}; + + &-approaching-limit { + background: ${colors.error}; + } +`); diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 7840686b..8cf212ee 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -11,13 +11,13 @@ import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel'; import * as commands from 'app/client/components/commands'; import {CursorPos} from 'app/client/components/Cursor'; import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor"; -import {DataTables} from 'app/client/components/DataTables'; import {DocComm, DocUserAction} from 'app/client/components/DocComm'; import * as DocConfigTab from 'app/client/components/DocConfigTab'; import {Drafts} from "app/client/components/Drafts"; import {EditorMonitor} from "app/client/components/EditorMonitor"; import * as GridView from 'app/client/components/GridView'; import {Importer} from 'app/client/components/Importer'; +import {RawData} from 'app/client/components/RawData'; import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; import {ViewLayout} from 'app/client/components/ViewLayout'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; @@ -382,7 +382,7 @@ export class GristDoc extends DisposableWithEvents { dom.domComputed(this.activeViewId, (viewId) => ( viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) : viewId === 'acl' ? dom.create((owner) => owner.autoDispose(AccessRules.create(this, this))) : - viewId === 'data' ? dom.create((owner) => owner.autoDispose(DataTables.create(this, this))) : + viewId === 'data' ? dom.create((owner) => owner.autoDispose(RawData.create(this, this))) : viewId === 'GristDocTour' ? null : dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId))) )), diff --git a/app/client/components/RawData.ts b/app/client/components/RawData.ts new file mode 100644 index 00000000..1424ca91 --- /dev/null +++ b/app/client/components/RawData.ts @@ -0,0 +1,136 @@ +import * as commands from 'app/client/components/commands'; +import {DataTables} from 'app/client/components/DataTables'; +import {DocumentUsage} from 'app/client/components/DocumentUsage'; +import {GristDoc} from 'app/client/components/GristDoc'; +import {printViewSection} from 'app/client/components/Printing'; +import {buildViewSectionDom, ViewSectionHelper} from 'app/client/components/ViewLayout'; +import {colors, mediaSmall, vars} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {Disposable, dom, fromKo, makeTestId, styled} from 'grainjs'; +import {reportError} from 'app/client/models/errors'; + +const testId = makeTestId('test-raw-data-'); + +export class RawData extends Disposable { + constructor(private _gristDoc: GristDoc) { + super(); + const commandGroup = { + cancel: () => { this._close(); }, + printSection: () => { printViewSection(null, this._gristDoc.viewModel.activeSection()).catch(reportError); }, + }; + this.autoDispose(commands.createGroup(commandGroup, this, true)); + } + + public buildDom() { + // Handler to close the lightbox. + const close = this._close.bind(this); + + return cssContainer( + dom.create(DataTables, this._gristDoc), + dom.create(DocumentUsage, this._gristDoc.docPageModel), + /*************** Lightbox section **********/ + dom.domComputedOwned(fromKo(this._gristDoc.viewModel.activeSection), (owner, viewSection) => { + if (!viewSection.getRowId()) { + return null; + } + ViewSectionHelper.create(owner, this._gristDoc, viewSection); + return cssOverlay( + testId('overlay'), + cssSectionWrapper( + buildViewSectionDom({ + gristDoc: this._gristDoc, + sectionRowId: viewSection.getRowId(), + draggable: false, + focusable: false, + onRename: this._renameSection.bind(this) + }) + ), + cssCloseButton('CrossBig', + testId('close-button'), + dom.on('click', close) + ), + // Close the lightbox when user clicks exactly on the overlay. + dom.on('click', (ev, elem) => void (ev.target === elem ? close() : null)) + ); + }), + ); + } + + private _close() { + this._gristDoc.viewModel.activeSectionId(0); + } + + private async _renameSection(name: string) { + // here we will rename primary page for active primary viewSection + const primaryViewName = this._gristDoc.viewModel.activeSection.peek().table.peek().primaryView.peek().name; + await primaryViewName.saveOnly(name); + } +} + +const cssContainer = styled('div', ` + overflow-y: auto; + position: relative; + height: 100%; + padding: 32px 64px 24px 64px; + @media ${mediaSmall} { + & { + padding: 32px 24px 24px 24px; + } + } +`); + +const cssOverlay = styled('div', ` + z-index: 10; + background-color: ${colors.backdrop}; + inset: 0px; + height: 100%; + width: 100%; + padding: 32px 56px 0px 56px; + position: absolute; + @media ${mediaSmall} { + & { + padding: 22px; + padding-top: 30px; + } + } +`); + +const cssSectionWrapper = styled('div', ` + background: white; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 5px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + & .viewsection_content { + margin: 0px; + margin-top: 12px; + } + & .viewsection_title { + padding: 0px 12px; + } + & .filter_bar { + margin-left: 6px; + } +`); + +const cssCloseButton = styled(icon, ` + position: absolute; + top: 16px; + right: 16px; + height: 24px; + width: 24px; + cursor: pointer; + --icon-color: ${vars.primaryBg}; + &:hover { + --icon-color: ${colors.lighterGreen}; + } + @media ${mediaSmall} { + & { + top: 6px; + right: 6px; + } + } +`); diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index 5b3d49e9..eabc5cf0 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -31,14 +31,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse return cssTools( cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)), cssSectionHeader("TOOLS"), - - // TODO proper UI - // cssPageEntry( - // dom.domComputed(docPageModel.rowCount, rowCount => - // `${rowCount} of ${docPageModel.appModel.currentFeatures.baseMaxRowsPerDocument || "infinity"} rows used` - // ), - // ), - cssPageEntry( cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'), cssPageEntry.cls('-disabled', (use) => !use(canViewAccessRules)),