mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
02e69fb685
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
284 lines
10 KiB
TypeScript
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;
|
|
}
|
|
`);
|