mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
51a34835c5
Summary: For non-owners, the timing section of Document Settings is now disabled. For non-editors, the "Reload" section is disabled. Test Plan: Added a test case for timing being disabled. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4275
512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
/**
|
|
* This module export a component for editing some document settings consisting of the timezone,
|
|
* (new settings to be added here ...).
|
|
*/
|
|
import {cssPrimarySmallLink, cssSmallButton, cssSmallLinkButton} from 'app/client/components/Forms/styles';
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
import {ACIndexImpl} from 'app/client/lib/ACIndex';
|
|
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
|
|
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
|
|
import {makeT} from 'app/client/lib/localization';
|
|
import {reportError} from 'app/client/models/AppModel';
|
|
import type {DocPageModel} from 'app/client/models/DocPageModel';
|
|
import {urlState} from 'app/client/models/gristUrlState';
|
|
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
|
import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss';
|
|
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
|
|
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
|
import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox';
|
|
import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {cssLink} from 'app/client/ui2018/links';
|
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
|
import {select} from 'app/client/ui2018/menus';
|
|
import {confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal} from 'app/client/ui2018/modals';
|
|
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
|
|
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
|
|
import {EngineCode} from 'app/common/DocumentSettings';
|
|
import {commonUrls, GristLoadConfig} from 'app/common/gristUrls';
|
|
import {not, propertyCompare} from 'app/common/gutil';
|
|
import {getCurrency, locales} from 'app/common/Locales';
|
|
import {isOwner, isOwnerOrEditor} from 'app/common/roles';
|
|
import {Computed, Disposable, dom, fromKo, IDisposableOwner, makeTestId, Observable, styled} from 'grainjs';
|
|
import * as moment from 'moment-timezone';
|
|
|
|
const t = makeT('DocumentSettings');
|
|
const testId = makeTestId('test-settings-');
|
|
|
|
export class DocSettingsPage extends Disposable {
|
|
private _docInfo = this._gristDoc.docInfo;
|
|
|
|
private _timezone = this._docInfo.timezone;
|
|
private _locale: KoSaveableObservable<string> = this._docInfo.documentSettingsJson.prop('locale');
|
|
private _currency: KoSaveableObservable<string|undefined> = this._docInfo.documentSettingsJson.prop('currency');
|
|
private _engine: Computed<EngineCode|undefined> = Computed.create(this, (
|
|
use => use(this._docInfo.documentSettingsJson.prop('engine'))
|
|
))
|
|
.onWrite(val => this._setEngine(val));
|
|
|
|
private _engines = getSupportedEngineChoices().map((engine) => ({
|
|
value: engine,
|
|
label: engine === 'python3' ? t(`python3 (recommended)`) : t(`python2 (legacy)`),
|
|
}));
|
|
|
|
constructor(private _gristDoc: GristDoc) {
|
|
super();
|
|
}
|
|
|
|
public buildDom() {
|
|
const canChangeEngine = getSupportedEngineChoices().length > 0;
|
|
const docPageModel = this._gristDoc.docPageModel;
|
|
const isTimingOn = this._gristDoc.isTimingOn;
|
|
const isDocOwner = isOwner(docPageModel.currentDoc.get());
|
|
const isDocEditor = isOwnerOrEditor(docPageModel.currentDoc.get());
|
|
|
|
return cssContainer(
|
|
dom.create(AdminSection, t('Document Settings'), [
|
|
dom.create(AdminSectionItem, {
|
|
id: 'timezone',
|
|
name: t('Time Zone'),
|
|
description: t('Default for DateTime columns'),
|
|
value: dom.create(cssTZAutoComplete, moment, fromKo(this._timezone), (val) => this._timezone.saveOnly(val)),
|
|
}),
|
|
dom.create(AdminSectionItem, {
|
|
id: 'locale',
|
|
name: t('Locale'),
|
|
description: t('For number and date formats'),
|
|
value: dom.create(cssLocalePicker, this._locale),
|
|
}),
|
|
dom.create(AdminSectionItem, {
|
|
id: 'currency',
|
|
name: t('Currency'),
|
|
description: t('For currency columns'),
|
|
value: dom.domComputed(fromKo(this._locale), (l) =>
|
|
dom.create(cssCurrencyPicker, fromKo(this._currency), (val) => this._currency.saveOnly(val),
|
|
{defaultCurrencyLabel: t("Local currency ({{currency}})", {currency: getCurrency(l)})})
|
|
)
|
|
}),
|
|
]),
|
|
|
|
dom.create(AdminSection, t('Data Engine'), [
|
|
dom.create(AdminSectionItem, {
|
|
id: 'timings',
|
|
name: t('Formula timer'),
|
|
description: dom('div',
|
|
dom.maybe(isTimingOn, () => cssRedText(t('Timing is on') + '...')),
|
|
dom.maybe(not(isTimingOn), () => t('Find slow formulas')),
|
|
testId('timing-desc')
|
|
),
|
|
value: dom.domComputed(isTimingOn, (timingOn) => {
|
|
if (timingOn) {
|
|
return dom('div', {style: 'display: flex; gap: 4px'},
|
|
cssPrimarySmallLink(
|
|
t('Stop timing...'),
|
|
urlState().setHref({docPage: 'timing'}),
|
|
{target: '_blank'},
|
|
testId('timing-stop')
|
|
)
|
|
);
|
|
} else {
|
|
return cssSmallButton(t('Start timing'),
|
|
dom.on('click', this._startTiming.bind(this)),
|
|
testId('timing-start')
|
|
);
|
|
}
|
|
}),
|
|
expandedContent: dom('div', t(
|
|
'Once you start timing, Grist will measure the time it takes to evaluate each formula. ' +
|
|
'This allows diagnosing which formulas are responsible for slow performance when a ' +
|
|
'document is first opened, or when a document responds to changes.'
|
|
)),
|
|
disabled: isDocOwner ? false : t('Only available to document owners'),
|
|
}),
|
|
|
|
dom.create(AdminSectionItem, {
|
|
id: 'reload',
|
|
name: t('Reload'),
|
|
description: t('Hard reset of data engine'),
|
|
value: cssSmallButton(t('Reload data engine'), dom.on('click', this._reloadEngine.bind(this, true))),
|
|
disabled: isDocEditor ? false : t('Only available to document editors'),
|
|
}),
|
|
|
|
canChangeEngine ? dom.create(AdminSectionItem, {
|
|
id: 'python',
|
|
name: t('Python'),
|
|
description: t('Python version used'),
|
|
value: cssSelect(this._engine, this._engines),
|
|
}) : null,
|
|
]),
|
|
|
|
dom.create(AdminSection, t('API'), [
|
|
dom.create(AdminSectionItem, {
|
|
id: 'documentId',
|
|
name: t('Document ID'),
|
|
description: t('ID for API use'),
|
|
value: cssHoverWrapper(
|
|
cssInput(docPageModel.currentDocId.get(), {tabIndex: "-1"}, clickToSelect(), readonly()),
|
|
cssCopyButton(
|
|
cssIcon('Copy'),
|
|
hoverTooltip(t('Copy to clipboard'), {
|
|
key: TOOLTIP_KEY,
|
|
}),
|
|
copyHandler(() => docPageModel.currentDocId.get()!, t("Document ID copied to clipboard")),
|
|
),
|
|
),
|
|
expandedContent: dom('div',
|
|
cssWrap(
|
|
t('Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}', {
|
|
apiURL: cssLink({href: commonUrls.helpAPI, target: '_blank'}, t('API documentation.')),
|
|
docId: dom('code', 'docId')
|
|
})
|
|
),
|
|
dom.domComputed(urlState().makeUrl({
|
|
api: true,
|
|
docPage: undefined,
|
|
doc: docPageModel.currentDocId.get(),
|
|
}), url => [
|
|
cssWrap(t('Base doc URL: {{docApiUrl}}', {
|
|
docApiUrl: cssCopyLink(
|
|
{href: url},
|
|
dom('span', url),
|
|
copyHandler(() => url, t("API URL copied to clipboard")),
|
|
hoverTooltip(t('Copy to clipboard'), {
|
|
key: TOOLTIP_KEY,
|
|
}),
|
|
)
|
|
})),
|
|
]),
|
|
),
|
|
}),
|
|
dom.create(AdminSectionItem, {
|
|
id: 'api-console',
|
|
name: t('API Console'),
|
|
description: t('Try API calls from the browser'),
|
|
value: cssSmallLinkButton(t('API console'), {
|
|
target: '_blank',
|
|
href: getApiConsoleLink(docPageModel),
|
|
}),
|
|
}),
|
|
|
|
dom.create(AdminSectionItem, {
|
|
id: 'webhooks',
|
|
name: t('Webhooks'),
|
|
description: t('Notify other services on doc changes'),
|
|
value: cssSmallLinkButton(t('Manage webhooks'), urlState().setLinkUrl({docPage: 'webhook'})),
|
|
}),
|
|
]),
|
|
);
|
|
}
|
|
|
|
private async _reloadEngine(ask = true) {
|
|
const docPageModel = this._gristDoc.docPageModel;
|
|
const handler = async () => {
|
|
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
|
document.location.reload();
|
|
};
|
|
if (!ask) {
|
|
return handler();
|
|
}
|
|
confirmModal(t('Reload data engine?'), t('Reload'), handler, {
|
|
explanation: t(
|
|
'This will perform a hard reload of the data engine. This ' +
|
|
'may help if the data engine is stuck in an infinite loop, is ' +
|
|
'indefinitely processing the latest change, or has crashed. ' +
|
|
'No data will be lost, except possibly currently pending actions.'
|
|
)
|
|
});
|
|
}
|
|
|
|
private async _setEngine(val: EngineCode|undefined) {
|
|
confirmModal(t('Save and Reload'), t('Ok'), () => this._doSetEngine(val));
|
|
}
|
|
|
|
private async _startTiming() {
|
|
const docPageModel = this._gristDoc.docPageModel;
|
|
modal((ctl, owner) => {
|
|
this.onDispose(() => ctl.close());
|
|
const selected = Observable.create<Option>(owner, Option.Adhoc);
|
|
const page = Observable.create<TimingModalPage>(owner, TimingModalPage.Start);
|
|
|
|
const startTiming = async () => {
|
|
if (selected.get() === Option.Reload) {
|
|
page.set(TimingModalPage.Spinner);
|
|
await this._gristDoc.docApi.startTiming();
|
|
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
|
ctl.close();
|
|
urlState().pushUrl({docPage: 'timing'}).catch(reportError);
|
|
} else {
|
|
await this._gristDoc.docApi.startTiming();
|
|
ctl.close();
|
|
}
|
|
};
|
|
|
|
const startPage = () => [
|
|
cssRadioCheckboxOptions(
|
|
dom.style('max-width', '400px'),
|
|
radioCheckboxOption(selected, Option.Adhoc, dom('div',
|
|
dom('div',
|
|
dom('strong', t('Start timing')),
|
|
),
|
|
dom('div',
|
|
dom.style('margin-top', '8px'),
|
|
dom('span', t('You can make changes to the document, then stop timing to see the results.'))
|
|
),
|
|
testId('timing-modal-option-adhoc'),
|
|
)),
|
|
radioCheckboxOption(selected, Option.Reload, dom('div',
|
|
dom('div',
|
|
dom('strong', t('Time reload')),
|
|
),
|
|
dom('div',
|
|
dom.style('margin-top', '8px'),
|
|
dom('span', t('Force reload the document while timing formulas, and show the result.'))
|
|
),
|
|
testId('timing-modal-option-reload'),
|
|
))
|
|
),
|
|
cssModalButtons(
|
|
bigPrimaryButton(t(`Start timing`),
|
|
dom.on('click', startTiming),
|
|
testId('timing-modal-confirm'),
|
|
),
|
|
bigBasicButton(t('Cancel'), dom.on('click', () => ctl.close()), testId('timing-modal-cancel')),
|
|
)
|
|
];
|
|
|
|
const spinnerPage = () => [
|
|
cssSpinner(
|
|
loadingSpinner(),
|
|
testId('timing-modal-spinner'),
|
|
dom.style('width', 'fit-content')
|
|
),
|
|
];
|
|
|
|
return [
|
|
cssModalTitle(t(`Formula timer`)),
|
|
dom.domComputed(page, (p) => p === TimingModalPage.Start ? startPage() : spinnerPage()),
|
|
testId('timing-modal'),
|
|
];
|
|
});
|
|
}
|
|
|
|
private async _doSetEngine(val: EngineCode|undefined) {
|
|
const docPageModel = this._gristDoc.docPageModel;
|
|
if (this._engine.get() !== val) {
|
|
await this._docInfo.documentSettingsJson.prop('engine').saveOnly(val);
|
|
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function getApiConsoleLink(docPageModel: DocPageModel) {
|
|
const url = new URL(location.href);
|
|
url.pathname = '/apiconsole';
|
|
url.searchParams.set('docId', docPageModel.currentDocId.get()!);
|
|
// Some extra question marks to placate a test fixture at test/fixtures/projects/DocumentSettings.ts
|
|
url.searchParams.set('workspaceId', String(docPageModel.currentWorkspace?.get()?.id || ''));
|
|
url.searchParams.set('orgId', String(docPageModel.appModel?.topAppModel.currentSubdomain.get()));
|
|
return url.href;
|
|
}
|
|
|
|
type LocaleItem = ACSelectItem & {locale?: string};
|
|
|
|
function buildLocaleSelect(
|
|
owner: IDisposableOwner,
|
|
locale: KoSaveableObservable<string>,
|
|
) {
|
|
const localeList: LocaleItem[] = locales.map(l => ({
|
|
value: l.name, // Use name as a value, we will translate the name into the locale on save
|
|
label: l.name,
|
|
locale: l.code,
|
|
cleanText: l.name.trim().toLowerCase(),
|
|
})).sort(propertyCompare("label"));
|
|
const acIndex = new ACIndexImpl<LocaleItem>(localeList, {maxResults: 200, keepOrder: true});
|
|
// AC select will show the value (in this case locale) not a label when something is selected.
|
|
// To show the label - create another observable that will be in sync with the value, but
|
|
// will contain text.
|
|
const textObs = Computed.create(owner, use => {
|
|
const localeCode = use(locale);
|
|
const localeName = locales.find(l => l.code === localeCode)?.name || localeCode;
|
|
return localeName;
|
|
});
|
|
return buildACSelect(owner,
|
|
{
|
|
acIndex, valueObs: textObs,
|
|
save(_value, item: LocaleItem | undefined) {
|
|
if (!item) { throw new Error("Invalid locale"); }
|
|
locale.saveOnly(item.locale!).catch(reportError);
|
|
},
|
|
},
|
|
testId("locale-autocomplete")
|
|
);
|
|
}
|
|
|
|
const cssContainer = styled('div', `
|
|
overflow-y: auto;
|
|
position: relative;
|
|
height: 100%;
|
|
padding: 32px 64px 24px 64px;
|
|
color: ${theme.text};
|
|
@media ${mediaSmall} {
|
|
& {
|
|
padding: 32px 24px 24px 24px;
|
|
}
|
|
}
|
|
`);
|
|
|
|
const cssCopyButton = styled('div', `
|
|
position: absolute;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
width: 24px;
|
|
right: 0;
|
|
top: 0;
|
|
--icon-color: ${theme.lightText};
|
|
&:hover {
|
|
--icon-color: ${colors.lightGreen};
|
|
}
|
|
`);
|
|
|
|
const cssIcon = styled(icon, `
|
|
`);
|
|
|
|
const cssInput = styled('div', `
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
width: 100%;
|
|
min-width: 180px;
|
|
height: 100%;
|
|
padding: 5px;
|
|
padding-right: 20px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`);
|
|
|
|
const cssHoverWrapper = styled('div', `
|
|
max-width: 280px;
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
text-wrap: nowrap;
|
|
display: inline-block;
|
|
cursor: pointer;
|
|
transition: all 0.05s;
|
|
border-radius: 4px;
|
|
border-color: ${theme.inputBorder};
|
|
border-style: solid;
|
|
border-width: 1px;
|
|
height: 30px;
|
|
align-items: center;
|
|
position: relative;
|
|
`);
|
|
|
|
// This matches the style used in showProfileModal in app/client/ui/AccountWidget.
|
|
|
|
|
|
// Check which engines can be selected in the UI, if any.
|
|
export function getSupportedEngineChoices(): EngineCode[] {
|
|
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
|
|
return gristConfig.supportEngines || [];
|
|
}
|
|
|
|
const cssSelect = styled(select, `
|
|
min-width: 170px; /* to match the width of the timezone picker */
|
|
`);
|
|
|
|
const TOOLTIP_KEY = 'copy-on-settings';
|
|
|
|
|
|
function copyHandler(value: () => string, confirmation: string) {
|
|
return dom.on('click', async (e, d) => {
|
|
e.stopImmediatePropagation();
|
|
e.preventDefault();
|
|
showTransientTooltip(d as Element, confirmation, {
|
|
key: TOOLTIP_KEY
|
|
});
|
|
await copyToClipboard(value());
|
|
});
|
|
}
|
|
|
|
function readonly() {
|
|
return [
|
|
{ contentEditable: 'false', spellcheck: 'false' },
|
|
];
|
|
}
|
|
|
|
function clickToSelect() {
|
|
return dom.on('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(e.target as Node);
|
|
const selection = window.getSelection();
|
|
if (selection) {
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enum for the different pages of the timing modal.
|
|
*/
|
|
enum TimingModalPage {
|
|
Start, // The initial page with options to start timing.
|
|
Spinner, // The page with a spinner while we are starting timing and reloading the document.
|
|
}
|
|
|
|
/**
|
|
* Enum for the different options in the timing modal.
|
|
*/
|
|
enum Option {
|
|
/**
|
|
* Start timing and immediately forces a reload of the document and waits for the
|
|
* document to be loaded, to show the results.
|
|
*/
|
|
Reload,
|
|
/**
|
|
* Just starts the timing, without reloading the document.
|
|
*/
|
|
Adhoc,
|
|
}
|
|
|
|
// A version that is not underlined, and on hover mouse pointer indicates that copy is available
|
|
const cssCopyLink = styled(cssLink, `
|
|
word-wrap: break-word;
|
|
&:hover {
|
|
border-radius: 4px;
|
|
text-decoration: none;
|
|
background: ${theme.lightHover};
|
|
outline-color: ${theme.linkHover};
|
|
outline-offset: 1px;
|
|
}
|
|
`);
|
|
|
|
const cssAutoComplete = `
|
|
width: 172px;
|
|
cursor: pointer;
|
|
& input {
|
|
text-overflow: ellipsis;
|
|
padding-right: 24px;
|
|
}
|
|
`;
|
|
|
|
const cssTZAutoComplete = styled(buildTZAutocomplete, cssAutoComplete);
|
|
const cssCurrencyPicker = styled(buildCurrencyPicker, cssAutoComplete);
|
|
const cssLocalePicker = styled(buildLocaleSelect, cssAutoComplete);
|
|
|
|
const cssWrap = styled('p', `
|
|
overflow-wrap: anywhere;
|
|
& * {
|
|
word-break: break-all;
|
|
}
|
|
`);
|
|
|
|
const cssRedText = styled('span', `
|
|
color: ${theme.errorText};
|
|
`);
|