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