gristlabs_grist-core/app/client/ui/Tools.ts
Alex Hall 02e69fb685 (core) Crudely show row count and limit in UI
Summary:
Add rowCount returned from sandbox when applying user actions to ActionGroup which is broadcast to clients.

Add rowCount to ActiveDoc and update it after applying user actions.

Add rowCount to OpenLocalDocResult using ActiveDoc value, to show when a client opens a doc before any user actions happen.

Add rowCount observable to DocPageModel which is set when the doc is opened and when action groups are received.

Add crude UI (commented out) in Tool.ts showing the row count and the limit in AppModel.currentFeatures. The actual UI doesn't have a place to go yet.

Followup tasks:

- Real, pretty UI
- Counts per table
- Keep count(s) secret from users with limited access?
- Data size indicator?
- Banner when close to or above limit
- Measure row counts outside of sandbox to avoid spoofing with formula
- Handle changes to the limit when the plan is changed or extra rows are purchased

Test Plan: Tested UI manually, including with free team site, opening a fresh doc, opening an initialised doc, adding rows, undoing, and changes from another tab. Automated tests seem like they should wait for a proper UI.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3318
2022-03-14 21:49:32 +02:00

284 lines
10 KiB
TypeScript

import {GristDoc} from 'app/client/components/GristDoc';
import {loadGristDoc} from 'app/client/lib/imports';
import {urlState} from 'app/client/models/gristUrlState';
import {getUserOrgPrefObs} from 'app/client/models/UserPrefs';
import {showExampleCard} from 'app/client/ui/ExampleCard';
import {examples} from 'app/client/ui/ExampleInfo';
import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall,
cssPageIcon, cssPageLink, cssSectionHeader, cssSpacer, cssSplitPageEntry,
cssTools} from 'app/client/ui/LeftPanelCommon';
import {hoverTooltip, tooltipCloseButton} from 'app/client/ui/tooltips';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {menuAnnotate} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {userOverrideParams} from 'app/common/gristUrls';
import {Computed, Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
const testId = makeTestId('test-tools-');
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
const docPageModel = gristDoc.docPageModel;
const isOwner = docPageModel.currentDoc.get()?.access === 'owners';
const isOverridden = Boolean(docPageModel.userOverride.get());
const hasDocTour = Computed.create(owner, use =>
use(gristDoc.docModel.allTableIds.getObservable()).includes('GristDocTour'));
const canViewAccessRules = observable(false);
function updateCanViewAccessRules() {
canViewAccessRules.set((isOwner && !isOverridden) ||
gristDoc.docModel.rules.getNumRows() > 0);
}
owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));
updateCanViewAccessRules();
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)),
dom.domComputed(canViewAccessRules, (_canViewAccessRules) => {
return cssPageLink(
cssPageIcon('EyeShow'),
cssLinkText('Access Rules',
menuAnnotate('Beta', cssBetaTag.cls(''))
),
_canViewAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null,
isOverridden ? addRevertViewAsUI() : null,
);
}),
testId('access-rules'),
),
// Raw data - for now hidden.
// cssPageEntry(
// cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'),
// cssPageLink(
// cssPageIcon('Database'),
// cssLinkText('Raw data'),
// testId('raw'),
// urlState().setLinkUrl({docPage: 'data'})
// )
// ),
cssPageEntry(
cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'),
dom.on('click', () => gristDoc.showTool('docHistory')))
),
// TODO: polish validation and add it back
dom.maybe((use) => use(gristDoc.app.features).validationsTool, () =>
cssPageEntry(
cssPageLink(cssPageIcon('Validation'), cssLinkText('Validate Data'), testId('validate'),
dom.on('click', () => gristDoc.showTool('validations'))))
),
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'code'),
cssPageLink(cssPageIcon('Code'),
cssLinkText('Code View'),
urlState().setLinkUrl({docPage: 'code'})
),
testId('code'),
),
cssSpacer(),
dom.maybe(docPageModel.currentDoc, (doc) => {
const ex = examples.find(e => e.urlId === doc.urlId);
if (!ex || !ex.tutorialUrl) { return null; }
return cssPageEntry(
cssPageLink(cssPageIcon('Page'), cssLinkText('How-to Tutorial'), testId('tutorial'),
{href: ex.tutorialUrl, target: '_blank'},
cssExampleCardOpener(
icon('TypeDetails'),
testId('welcome-opener'),
automaticHelpTool(
(info) => showExampleCard(ex, info),
gristDoc,
"seenExamples",
ex.id
),
),
),
);
}),
// Show the 'Tour of this Document' button if a GristDocTour table exists.
dom.maybe(hasDocTour, () =>
cssSplitPageEntry(
cssPageEntryMain(
cssPageLink(cssPageIcon('Page'),
cssLinkText('Tour of this Document'),
automaticHelpTool(
async ({markAsSeen}) => {
const gristDocModule = await loadGristDoc();
await gristDocModule.startDocTour(gristDoc.docData, gristDoc.docComm, markAsSeen);
},
gristDoc,
"seenDocTours",
gristDoc.docId()
),
testId('doctour'),
),
),
!isOwner ? null : cssPageEntrySmall(
cssPageLink(cssPageIcon('Remove'),
dom.on('click', () => confirmModal('Delete document tour?', 'Delete', () =>
gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour']))
),
testId('remove-doctour')
),
)
),
),
createHelpTools(docPageModel.appModel, false)
);
}
/**
* Helper for showing users some kind of help (example cards or document tours)
* automatically if they haven't seen it before, or if they click
* on some element to explicitly show it again. Put this in said dom element,
* and it will provide the onclick handler and a handler which automatically
* shows when the dom element is attached, both by calling showFunc.
*
* prefKey is a key for a list of identifiers saved in user preferences.
* itemId should be a single identifier that fits in that list.
* If itemId is already present then the help will not be shown automatically,
* otherwise it will be added to the list and saved under prefKey
* when info.markAsSeen() is called.
*/
function automaticHelpTool(
showFunc: (info: AutomaticHelpToolInfo) => void,
gristDoc: GristDoc,
prefKey: 'seenExamples' | 'seenDocTours',
itemId: number | string
) {
function show(elem: HTMLElement, reopen: boolean) {
const prefObs: Observable<typeof itemId[] | undefined> = getUserOrgPrefObs(gristDoc.userOrgPrefs, prefKey);
const seenIds = prefObs.get() || [];
// If this help was previously dismissed, don't show it again, unless the user is reopening it.
if (!reopen && seenIds.includes(itemId)) {
return;
}
// When the help is closed, if it's the first time it's dismissed, save this fact, to avoid
// showing it automatically again in the future.
function markAsSeen() {
try {
if (!seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.add(itemId);
prefObs.set([...seen].sort());
}
} catch (e) {
// If we fail to save this preference, it's probably not worth alerting the user about,
// so just log to console.
// tslint:disable-next-line:no-console
console.warn("Failed to save userPref " + prefKey, e);
}
}
showFunc({elem, reopen, markAsSeen});
}
return [
dom.on('click', (ev, elem) => {
ev.preventDefault();
show(elem as HTMLElement, true);
}),
(elem: HTMLElement) => {
// Once the trigger element is attached to DOM, show the help
setTimeout(() => show(elem, false), 0);
}
];
}
/** Values which may be useful when showing an automatic help tool */
export interface AutomaticHelpToolInfo {
// Element where automaticHelpTool is attached, typically a button,
// which shows the help when clicked
elem: HTMLElement;
// true if the help was shown explicitly by clicking elem,
// false if it's being shown automatically to new users
reopen: boolean;
// Call this when the user explicitly dismisses the help to
// remember this in user preferences and not show it automatically on next load
markAsSeen: () => void;
}
// When viewing a page as another user, the "Access Rules" page link includes a button to revert
// the user and open the page, and a click on the page link shows a tooltip to revert.
function addRevertViewAsUI() {
return [
// A button that allows reverting back to yourself.
dom('a',
cssExampleCardOpener.cls(''),
cssRevertViewAsButton.cls(''),
icon('Convert'),
urlState().setHref(userOverrideParams(null, {docPage: 'acl'})),
dom.on('click', (ev) => ev.stopPropagation()), // Avoid refreshing the tooltip.
testId('revert-view-as'),
),
// A tooltip that allows reverting back to yourself.
hoverTooltip((ctl) =>
cssConvertTooltip(icon('Convert'),
cssLink('Return to viewing as yourself',
urlState().setHref(userOverrideParams(null, {docPage: 'acl'})),
),
tooltipCloseButton(ctl),
),
{openOnClick: true}
),
];
}
const cssConvertTooltip = styled('div', `
display: flex;
align-items: center;
--icon-color: ${colors.lightGreen};
& > .${cssLink.className} {
margin-left: 8px;
}
`);
const cssExampleCardOpener = styled('div', `
cursor: pointer;
margin-right: 4px;
margin-left: auto;
border-radius: 16px;
border-radius: 3px;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
--icon-color: ${colors.light};
background-color: ${colors.lightGreen};
&:hover {
background-color: ${colors.darkGreen};
}
.${cssTools.className}-collapsed & {
display: none;
}
`);
const cssRevertViewAsButton = styled(cssExampleCardOpener, `
background-color: ${colors.darkGrey};
&:hover {
background-color: ${colors.slate};
}
`);
const cssBetaTag = styled('div', `
.${cssPageEntry.className}-disabled & {
opacity: 0.4;
}
`);