/** * 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 = this._docInfo.documentSettingsJson.prop('locale'); private _currency: KoSaveableObservable = this._docInfo.documentSettingsJson.prop('currency'); private _engine: Computed = 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