2023-01-09 16:26:09 +00:00
|
|
|
import {ACLUsersPopup} from 'app/client/aclui/ACLUsers';
|
2022-10-28 16:11:08 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2022-01-04 00:20:58 +00:00
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
|
|
import {urlState} from 'app/client/models/gristUrlState';
|
2022-03-21 03:41:59 +00:00
|
|
|
import {getUserOrgPrefObs, markAsSeen} from 'app/client/models/UserPrefs';
|
2022-01-04 00:20:58 +00:00
|
|
|
import {showExampleCard} from 'app/client/ui/ExampleCard';
|
2022-10-28 16:11:08 +00:00
|
|
|
import {buildExamples} from 'app/client/ui/ExampleInfo';
|
2023-01-09 16:26:09 +00:00
|
|
|
import {createHelpTools, cssLinkText, cssMenuTrigger, cssPageEntry, cssPageEntryMain, cssPageEntrySmall,
|
2022-01-04 00:20:58 +00:00
|
|
|
cssPageIcon, cssPageLink, cssSectionHeader, cssSpacer, cssSplitPageEntry,
|
|
|
|
cssTools} from 'app/client/ui/LeftPanelCommon';
|
2022-09-06 01:51:57 +00:00
|
|
|
import {theme} from 'app/client/ui2018/cssVars';
|
2022-01-04 00:20:58 +00:00
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
|
|
import {confirmModal} from 'app/client/ui2018/modals';
|
2022-05-26 06:47:26 +00:00
|
|
|
import {isOwner} from 'app/common/roles';
|
2022-03-21 03:41:59 +00:00
|
|
|
import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
|
2023-01-09 16:26:09 +00:00
|
|
|
import noop from 'lodash/noop';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
const testId = makeTestId('test-tools-');
|
2022-10-28 16:11:08 +00:00
|
|
|
const t = makeT('Tools');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
|
2022-03-14 18:16:30 +00:00
|
|
|
const docPageModel = gristDoc.docPageModel;
|
2022-05-26 06:47:26 +00:00
|
|
|
const isDocOwner = isOwner(docPageModel.currentDoc.get());
|
2022-03-14 18:16:30 +00:00
|
|
|
const isOverridden = Boolean(docPageModel.userOverride.get());
|
2021-03-25 17:37:09 +00:00
|
|
|
const canViewAccessRules = observable(false);
|
|
|
|
function updateCanViewAccessRules() {
|
2022-05-26 06:47:26 +00:00
|
|
|
canViewAccessRules.set((isDocOwner && !isOverridden) ||
|
2021-03-25 17:37:09 +00:00
|
|
|
gristDoc.docModel.rules.getNumRows() > 0);
|
|
|
|
}
|
|
|
|
owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));
|
|
|
|
updateCanViewAccessRules();
|
2020-10-02 15:10:00 +00:00
|
|
|
return cssTools(
|
|
|
|
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
|
2022-12-06 13:57:29 +00:00
|
|
|
cssSectionHeader(t("TOOLS")),
|
2021-03-25 19:17:25 +00:00
|
|
|
cssPageEntry(
|
|
|
|
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
|
|
|
|
cssPageEntry.cls('-disabled', (use) => !use(canViewAccessRules)),
|
2023-01-09 16:26:09 +00:00
|
|
|
dom.domComputedOwned(canViewAccessRules, (computedOwner, _canViewAccessRules) => {
|
|
|
|
const aclUsers = ACLUsersPopup.create(computedOwner, docPageModel);
|
|
|
|
if (_canViewAccessRules) {
|
|
|
|
aclUsers.load()
|
|
|
|
// getUsersForViewAs() could fail for couple good reasons (access deny to anon user,
|
|
|
|
// `document not found` when anon creates a new empty document, ...), users can have more
|
|
|
|
// info by opening acl page, so let's silently fail here.
|
|
|
|
.catch(noop);
|
|
|
|
}
|
2021-03-25 19:17:25 +00:00
|
|
|
return cssPageLink(
|
|
|
|
cssPageIcon('EyeShow'),
|
2023-01-04 15:17:08 +00:00
|
|
|
cssLinkText(t("Access Rules")),
|
2021-03-25 19:17:25 +00:00
|
|
|
_canViewAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null,
|
2023-01-09 16:26:09 +00:00
|
|
|
cssMenuTrigger(
|
|
|
|
icon('Dots'),
|
|
|
|
aclUsers.menu({
|
|
|
|
placement: 'bottom-start',
|
|
|
|
parentSelectorToMark: '.' + cssPageEntry.className
|
|
|
|
}),
|
|
|
|
|
|
|
|
// Clicks on the menu trigger shouldn't follow the link that it's contained in.
|
|
|
|
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
|
|
|
|
testId('access-rules-trigger'),
|
|
|
|
dom.show(use => use(aclUsers.isInitialized) && _canViewAccessRules),
|
|
|
|
),
|
2021-03-25 19:17:25 +00:00
|
|
|
);
|
|
|
|
}),
|
|
|
|
testId('access-rules'),
|
2020-12-04 23:29:29 +00:00
|
|
|
),
|
2022-04-27 17:46:24 +00:00
|
|
|
cssPageEntry(
|
|
|
|
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'),
|
|
|
|
cssPageLink(
|
|
|
|
cssPageIcon('Database'),
|
2022-12-06 13:57:29 +00:00
|
|
|
cssLinkText(t("Raw Data")),
|
2022-04-27 17:46:24 +00:00
|
|
|
testId('raw'),
|
|
|
|
urlState().setLinkUrl({docPage: 'data'})
|
|
|
|
)
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
cssPageEntry(
|
2022-12-06 13:57:29 +00:00
|
|
|
cssPageLink(cssPageIcon('Log'), cssLinkText(t("Document History")), testId('log'),
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.on('click', () => gristDoc.showTool('docHistory')))
|
|
|
|
),
|
|
|
|
// TODO: polish validation and add it back
|
|
|
|
dom.maybe((use) => use(gristDoc.app.features).validationsTool, () =>
|
|
|
|
cssPageEntry(
|
2022-12-06 13:57:29 +00:00
|
|
|
cssPageLink(cssPageIcon('Validation'), cssLinkText(t("Validate Data")), testId('validate'),
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.on('click', () => gristDoc.showTool('validations'))))
|
|
|
|
),
|
|
|
|
cssPageEntry(
|
|
|
|
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'code'),
|
|
|
|
cssPageLink(cssPageIcon('Code'),
|
2022-12-06 13:57:29 +00:00
|
|
|
cssLinkText(t("Code View")),
|
2020-10-02 15:10:00 +00:00
|
|
|
urlState().setLinkUrl({docPage: 'code'})
|
|
|
|
),
|
|
|
|
testId('code'),
|
|
|
|
),
|
2023-01-05 08:11:54 +00:00
|
|
|
cssPageEntry(
|
|
|
|
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'settings'),
|
|
|
|
cssPageLink(cssPageIcon('Settings'),
|
|
|
|
cssLinkText(t("Settings")),
|
|
|
|
urlState().setLinkUrl({docPage: 'settings'})
|
|
|
|
),
|
|
|
|
testId('settings'),
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
cssSpacer(),
|
2023-12-27 12:19:56 +00:00
|
|
|
// TODO make this look nice, then make it visible when the console is ready.
|
|
|
|
// For now let's keep it private, so this shouldn't be uncommented.
|
|
|
|
// cssPageEntry(
|
|
|
|
// cssPageLink(
|
|
|
|
// cssPageIcon('Code'),
|
|
|
|
// cssPageIcon('FieldLink'),
|
|
|
|
// cssLinkText(t("API Console")),
|
|
|
|
// {href: window.origin + '/apiconsole', target: '_blank'}
|
|
|
|
// ),
|
|
|
|
// testId('api'),
|
|
|
|
// ),
|
2022-03-14 18:16:30 +00:00
|
|
|
dom.maybe(docPageModel.currentDoc, (doc) => {
|
2022-10-28 16:11:08 +00:00
|
|
|
const ex = buildExamples().find(e => e.urlId === doc.urlId);
|
2020-10-02 15:10:00 +00:00
|
|
|
if (!ex || !ex.tutorialUrl) { return null; }
|
|
|
|
return cssPageEntry(
|
2022-12-06 13:57:29 +00:00
|
|
|
cssPageLink(cssPageIcon('Page'), cssLinkText(t("How-to Tutorial")), testId('tutorial'),
|
2020-10-02 15:10:00 +00:00
|
|
|
{href: ex.tutorialUrl, target: '_blank'},
|
|
|
|
cssExampleCardOpener(
|
|
|
|
icon('TypeDetails'),
|
|
|
|
testId('welcome-opener'),
|
2021-07-27 16:03:35 +00:00
|
|
|
automaticHelpTool(
|
|
|
|
(info) => showExampleCard(ex, info),
|
|
|
|
gristDoc,
|
|
|
|
"seenExamples",
|
|
|
|
ex.id
|
|
|
|
),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}),
|
2022-01-04 00:20:58 +00:00
|
|
|
// Show the 'Tour of this Document' button if a GristDocTour table exists.
|
2023-03-22 13:48:50 +00:00
|
|
|
dom.maybe(use => use(gristDoc.docModel.hasDocTour) && !use(gristDoc.docModel.isTutorial), () =>
|
2022-01-04 00:20:58 +00:00
|
|
|
cssSplitPageEntry(
|
|
|
|
cssPageEntryMain(
|
|
|
|
cssPageLink(cssPageIcon('Page'),
|
2022-12-06 13:57:29 +00:00
|
|
|
cssLinkText(t("Tour of this Document")),
|
2022-03-21 03:41:59 +00:00
|
|
|
urlState().setLinkUrl({docTour: true}),
|
2022-01-04 00:20:58 +00:00
|
|
|
testId('doctour'),
|
|
|
|
),
|
2021-07-27 16:03:35 +00:00
|
|
|
),
|
2022-05-26 06:47:26 +00:00
|
|
|
!isDocOwner ? null : cssPageEntrySmall(
|
2022-01-04 00:20:58 +00:00
|
|
|
cssPageLink(cssPageIcon('Remove'),
|
2022-12-06 13:57:29 +00:00
|
|
|
dom.on('click', () => confirmModal(t("Delete document tour?"), t("Delete"), () =>
|
2022-01-04 00:20:58 +00:00
|
|
|
gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour']))
|
|
|
|
),
|
|
|
|
testId('remove-doctour')
|
|
|
|
),
|
|
|
|
)
|
2021-07-27 16:03:35 +00:00
|
|
|
),
|
|
|
|
),
|
2022-06-17 03:43:26 +00:00
|
|
|
createHelpTools(docPageModel.appModel),
|
2020-10-02 15:10:00 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-07-27 16:03:35 +00:00
|
|
|
/**
|
|
|
|
* 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) {
|
2022-01-04 00:20:58 +00:00
|
|
|
const prefObs: Observable<typeof itemId[] | undefined> = getUserOrgPrefObs(gristDoc.userOrgPrefs, prefKey);
|
2021-07-27 16:03:35 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-03-21 03:41:59 +00:00
|
|
|
showFunc({elem, reopen, markAsSeen: () => markAsSeen(prefObs, itemId)});
|
2021-07-27 16:03:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
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;
|
2022-09-06 01:51:57 +00:00
|
|
|
--icon-color: ${theme.iconButtonFg};
|
|
|
|
background-color: ${theme.iconButtonPrimaryBg};
|
2020-10-02 15:10:00 +00:00
|
|
|
&:hover {
|
2022-09-06 01:51:57 +00:00
|
|
|
background-color: ${theme.iconButtonPrimaryHoverBg};
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
2021-03-08 21:08:13 +00:00
|
|
|
.${cssTools.className}-collapsed & {
|
|
|
|
display: none;
|
|
|
|
}
|
|
|
|
`);
|