(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
This commit is contained in:
George Gevoian 2022-04-01 11:17:06 -07:00
parent bf271c822b
commit d8af25de9d
5 changed files with 309 additions and 118 deletions

View File

@ -1,33 +1,22 @@
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; 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 {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {localStorageObs} from 'app/client/lib/localStorageObs'; import {localStorageObs} from 'app/client/lib/localStorageObs';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';
import {TableRec} from 'app/client/models/DocModel'; import {TableRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
import {docList, docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
import {showTransientTooltip} from 'app/client/ui/tooltips'; import {showTransientTooltip} from 'app/client/ui/tooltips';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect'; import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
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 {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, MultiHolder, styled} from 'grainjs'; import {Disposable, dom, fromKo, makeTestId, MultiHolder, styled} from 'grainjs';
const testId = makeTestId('test-raw-data-'); const testId = makeTestId('test-raw-data-');
export class DataTables extends Disposable { export class DataTables extends Disposable {
private _popupVisible = Computed.create(this, use => Boolean(use(this._gristDoc.viewModel.activeSectionId)));
constructor(private _gristDoc: GristDoc) { constructor(private _gristDoc: GristDoc) {
super(); super();
const commandGroup = {
cancel: () => { this._close(); },
printSection: () => { printViewSection(null, this._gristDoc.viewModel.activeSection()).catch(reportError); },
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
} }
public buildDom() { public buildDom() {
@ -35,11 +24,9 @@ export class DataTables extends Disposable {
// Get the user id, to remember selected layout on the next visit. // Get the user id, to remember selected layout on the next visit.
const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0; const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0;
const view = holder.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list")); const view = holder.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list"));
// Handler to close the lightbox.
const close = this._close.bind(this);
return container( return container(
dom.autoDispose(holder), dom.autoDispose(holder),
docList( cssTableList(
/*************** List section **********/ /*************** List section **********/
testId('list'), testId('list'),
cssBetween( cssBetween(
@ -108,37 +95,7 @@ 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) { private _menuItems(t: TableRec) {
@ -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) { private _removeTable(t: TableRec) {
const {docModel} = this._gristDoc; const {docModel} = this._gristDoc;
function doRemove() { function doRemove() {
@ -177,9 +128,8 @@ export class DataTables extends Disposable {
} }
const container = styled('div', ` const container = styled('div', `
overflow: hidden; overflow-y: auto;
position: relative; position: relative;
height: 100%;
`); `);
const cssBetween = styled('div', ` const cssBetween = styled('div', `
@ -353,58 +303,8 @@ const cssDots = styled('div', `
margin-right: 8px; margin-right: 8px;
`); `);
const cssOverlay = styled('div', ` const cssTableList = styled('div', `
z-index: 10; overflow-y: auto;
background-color: ${css.colors.backdrop}; position: relative;
inset: 0px; margin-bottom: 56px;
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;
}
}
`); `);

View File

@ -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<NonNullable<DataLimitStatus>, 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<number | undefined>;
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};
}
`);

View File

@ -11,13 +11,13 @@ import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel';
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import {CursorPos} from 'app/client/components/Cursor'; import {CursorPos} from 'app/client/components/Cursor';
import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor"; import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor";
import {DataTables} from 'app/client/components/DataTables';
import {DocComm, DocUserAction} from 'app/client/components/DocComm'; import {DocComm, DocUserAction} from 'app/client/components/DocComm';
import * as DocConfigTab from 'app/client/components/DocConfigTab'; import * as DocConfigTab from 'app/client/components/DocConfigTab';
import {Drafts} from "app/client/components/Drafts"; import {Drafts} from "app/client/components/Drafts";
import {EditorMonitor} from "app/client/components/EditorMonitor"; import {EditorMonitor} from "app/client/components/EditorMonitor";
import * as GridView from 'app/client/components/GridView'; import * as GridView from 'app/client/components/GridView';
import {Importer} from 'app/client/components/Importer'; import {Importer} from 'app/client/components/Importer';
import {RawData} from 'app/client/components/RawData';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout'; import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
@ -382,7 +382,7 @@ export class GristDoc extends DisposableWithEvents {
dom.domComputed<IDocPage>(this.activeViewId, (viewId) => ( dom.domComputed<IDocPage>(this.activeViewId, (viewId) => (
viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) : viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) :
viewId === 'acl' ? dom.create((owner) => owner.autoDispose(AccessRules.create(this, 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 : viewId === 'GristDocTour' ? null :
dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId))) dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId)))
)), )),

View File

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

View File

@ -31,14 +31,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
return cssTools( return cssTools(
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)), cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
cssSectionHeader("TOOLS"), cssSectionHeader("TOOLS"),
// TODO proper UI
// cssPageEntry(
// dom.domComputed(docPageModel.rowCount, rowCount =>
// `${rowCount} of ${docPageModel.appModel.currentFeatures.baseMaxRowsPerDocument || "infinity"} rows used`
// ),
// ),
cssPageEntry( cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'), cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
cssPageEntry.cls('-disabled', (use) => !use(canViewAccessRules)), cssPageEntry.cls('-disabled', (use) => !use(canViewAccessRules)),