diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 564610c2..32d20fb8 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -19,6 +19,7 @@ import {EditorMonitor} from "app/client/components/EditorMonitor"; import * as GridView from 'app/client/components/GridView'; import {Importer} from 'app/client/components/Importer'; import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; +import {DocSettingsPage} from 'app/client/ui/DocumentSettings'; import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; import {ViewLayout} from 'app/client/components/ViewLayout'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; @@ -42,7 +43,6 @@ import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/mode import {App} from 'app/client/ui/App'; import {DocHistory} from 'app/client/ui/DocHistory'; import {startDocTour} from "app/client/ui/DocTour"; -import {showDocSettingsModal} from 'app/client/ui/DocumentSettings'; import {isTourActive} from "app/client/ui/OnBoardingPopups"; import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkFromId, selectBy} from 'app/client/ui/selectBy'; @@ -92,7 +92,7 @@ const t = makeT('GristDoc'); const G = getBrowserGlobals('document', 'window'); // Re-export some tools to move them from main webpack bundle to the one with GristDoc. -export {DocComm, showDocSettingsModal, startDocTour}; +export {DocComm, startDocTour}; export interface TabContent { showObs?: any; @@ -446,7 +446,7 @@ export class GristDoc extends DisposableWithEvents { public buildDom() { const isMaximized = Computed.create(this, use => use(this.sectionInPopup) !== null); const isPopup = Computed.create(this, use => { - return use(this.activeViewId) === 'data' // On Raw data page + return ['data', 'settings'].includes(use(this.activeViewId) as any) // On Raw data or doc settings pages || use(isMaximized) // Layout has a maximized section visible || typeof use(this._activeContent) === 'object'; // We are on show raw data popup }); @@ -458,6 +458,7 @@ export class GristDoc extends DisposableWithEvents { content === 'code' ? dom.create(CodeEditorPanel, this) : content === 'acl' ? dom.create(AccessRules, this) : content === 'data' ? dom.create(RawDataPage, this) : + content === 'settings' ? dom.create(DocSettingsPage, this) : content === 'GristDocTour' ? null : (typeof content === 'object') ? dom.create(owner => { // In case user changes a page, close the popup. diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 4940b208..5769c2b4 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -1,4 +1,3 @@ -import {loadGristDoc} from 'app/client/lib/imports'; import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState'; @@ -52,14 +51,13 @@ export class AccountWidget extends Disposable { */ private _makeAccountMenu(user: FullUser|null): DomElementArg[] { const currentOrg = this._appModel.currentOrg; - const gristDoc = this._docPageModel ? this._docPageModel.gristDoc.get() : null; // The 'Document Settings' item, when there is an open document. - const documentSettingsItem = (gristDoc ? - menuItem(async () => (await loadGristDoc()).showDocSettingsModal(gristDoc.docInfo, this._docPageModel!), - t("Document Settings"), - testId('dm-doc-settings')) : - null); + const documentSettingsItem = this._docPageModel ? menuItemLink( + urlState().setLinkUrl({docPage: 'settings'}), + t("Document Settings"), + testId('dm-doc-settings') + ) : null; // The item to toggle mobile mode (presence of viewport meta tag). const mobileModeToggle = menuItem(viewport.toggleViewport, diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index 83dd5b23..7f8f4084 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -3,110 +3,107 @@ * (new settings to be added here ...). */ import {makeT} from 'app/client/lib/localization'; -import {dom, IDisposableOwner, styled} from 'grainjs'; -import {Computed, Observable} from 'grainjs'; - - +import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs'; import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect"; +import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {ACIndexImpl} from "app/client/lib/ACIndex"; -import {loadMomentTimezone} from 'app/client/lib/imports'; -import {DocInfoRec} from 'app/client/models/DocModel'; -import {DocPageModel} from 'app/client/models/DocPageModel'; -import {testId, vars} from 'app/client/ui2018/cssVars'; +import {docListHeader} from "app/client/ui/DocMenuCss"; +import {showTransientTooltip} from 'app/client/ui/tooltips'; +import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {select} from 'app/client/ui2018/menus'; -import {saveModal} 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 {GristLoadConfig} from 'app/common/gristUrls'; import {propertyCompare} from "app/common/gutil"; import {getCurrency, locales} from "app/common/Locales"; +import {GristDoc} from 'app/client/components/GristDoc'; +import * as moment from "moment-timezone"; +import {KoSaveableObservable} from 'app/client/models/modelUtil'; +import {reportError} from 'app/client/models/AppModel'; +import {confirmModal} from 'app/client/ui2018/modals'; const t = makeT('DocumentSettings'); -/** - * Builds a simple saveModal for saving settings. - */ -export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: DocPageModel): Promise { - const moment = await loadMomentTimezone(); - return saveModal((ctl, owner) => { - const timezoneObs = Observable.create(owner, docInfo.timezone.peek()); +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)); - const docSettings = docInfo.documentSettingsJson.peek(); - const {locale, currency, engine} = docSettings; - const localeObs = Observable.create(owner, locale); - const currencyObs = Observable.create(owner, currency); - const engineObs = Observable.create(owner, engine); + constructor(private _gristDoc: GristDoc) { + super(); + } - // Check if server supports engine choices - if so, we will allow user to pick. + public buildDom() { const canChangeEngine = getSupportedEngineChoices().length > 0; + const docPageModel = this._gristDoc.docPageModel; - return { - title: t("Document Settings"), - body: [ - cssDataRow(t("This document's ID (for API use):")), - cssDataRow(dom('tt', docPageModel.currentDocId.get())), - cssDataRow(t("Time Zone:")), - cssDataRow(dom.create(buildTZAutocomplete, moment, timezoneObs, (val) => timezoneObs.set(val))), - cssDataRow(t("Locale:")), - cssDataRow(dom.create(buildLocaleSelect, localeObs)), - cssDataRow(t("Currency:")), - cssDataRow(dom.domComputed(localeObs, (l) => - dom.create(buildCurrencyPicker, currencyObs, (val) => currencyObs.set(val), - {defaultCurrencyLabel: t("Local currency ({{currency}})", {currency: getCurrency(l)})}) - )), - canChangeEngine ? [ - // Small easter egg: you can click on the skull-and-crossbones to - // force a reload of the document. - cssDataRow(t("Engine (experimental {{span}} change at own risk):", {span: - dom('span', '☠', - dom.style('cursor', 'pointer'), - dom.on('click', async () => { - await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); - document.location.reload(); - })) + return cssContainer( + cssHeader(t('Document Settings')), + cssDataRow(t("Time Zone:")), + cssDataRow( + dom.create(buildTZAutocomplete, moment, fromKo(this._timezone), (val) => this._timezone.saveOnly(val)) + ), + cssDataRow(t("Locale:")), + cssDataRow(dom.create(buildLocaleSelect, this._locale)), + cssDataRow(t("Currency:")), + cssDataRow(dom.domComputed(fromKo(this._locale), (l) => + dom.create(buildCurrencyPicker, fromKo(this._currency), (val) => this._currency.saveOnly(val), + {defaultCurrencyLabel: t("Local currency ({{currency}})", {currency: getCurrency(l)})}) + )), + canChangeEngine ? [ + // Small easter egg: you can click on the skull-and-crossbones to + // force a reload of the document. + cssDataRow(t("Engine (experimental {{span}} change at own risk):", {span: + dom('span', '☠', + dom.style('cursor', 'pointer'), + dom.on('click', async () => { + await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); + document.location.reload(); + })) })), - select(engineObs, getSupportedEngineChoices()), - ] : null, - ], - // Modal label is "Save", unless engine is changed. If engine is changed, the document will - // need a reload to switch engines, so we replace the label with "Save and Reload". - saveLabel: dom.text((use) => (use(engineObs) === docSettings.engine) ? t("Save") : t("Save and Reload")), - saveFunc: async () => { - await docInfo.updateColValues({ - timezone: timezoneObs.get(), - documentSettings: JSON.stringify({ - ...docInfo.documentSettingsJson.peek(), - locale: localeObs.get(), - currency: currencyObs.get(), - engine: engineObs.get() - }) - }); - // Reload the document if the engine is changed. - if (engineObs.get() !== docSettings.engine) { - await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); - } - }, - // If timezone, locale, or currency hasn't changed, disable the Save button. - saveDisabled: Computed.create(owner, - (use) => { - return ( - use(timezoneObs) === docInfo.timezone.peek() && - use(localeObs) === docSettings.locale && - use(currencyObs) === docSettings.currency && - use(engineObs) === docSettings.engine - ); - }) - }; - }); -} + select(this._engine, getSupportedEngineChoices()), + ] : null, + cssHeader(t('API')), + cssDataRow(t("This document's ID (for API use):")), + cssDataRow(cssHoverWrapper( + dom('tt', docPageModel.currentDocId.get()), + dom.on('click', async (e, d) => { + e.stopImmediatePropagation(); + e.preventDefault(); + showTransientTooltip(d, t("Document ID copied to clipboard"), { + key: 'copy-document-id' + }); + await copyToClipboard(docPageModel.currentDocId.get()!); + }), + )), + ); + } + private async _setEngine(val: EngineCode|undefined) { + confirmModal(t('Save and Reload'), t('Ok'), () => this._doSetEngine(val)); + } + + 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(); + } + } +} type LocaleItem = ACSelectItem & {locale?: string}; function buildLocaleSelect( owner: IDisposableOwner, - locale: Observable + locale: KoSaveableObservable, ) { const localeList: LocaleItem[] = locales.map(l => ({ value: l.name, // Use name as a value, we will translate the name into the locale on save @@ -118,26 +115,58 @@ function buildLocaleSelect( // 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 localeCode = locale.get(); - const localeName = locales.find(l => l.code === localeCode)?.name || localeCode; - const textObs = Observable.create(owner, localeName); + 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) { + save(_value, item: LocaleItem | undefined) { if (!item) { throw new Error("Invalid locale"); } - textObs.set(value); - locale.set(item.locale!); + locale.saveOnly(item.locale!).catch(reportError); }, }, testId("locale-autocomplete") ); } +const cssHeader = styled(docListHeader, ` + margin-bottom: 0; + &:not(:first-of-type) { + margin-top: 40px; + } +`); + +const cssContainer = styled('div', ` + overflow-y: auto; + position: relative; + height: 100%; + padding: 32px 64px 24px 64px; + max-width: 487px; + @media ${mediaSmall} { + & { + padding: 32px 24px 24px 24px; + } + } +`); + +const cssHoverWrapper = styled('div', ` + display: inline-block; + cursor: default; + color: ${theme.lightText}; + transition: background 0.05s; + &:hover { + background: ${theme.lightHover}; + } +`); + // This matches the style used in showProfileModal in app/client/ui/AccountWidget. const cssDataRow = styled('div', ` margin: 16px 0px; font-size: ${vars.largeFontSize}; + color: ${theme.text}; `); // Check which engines can be selected in the UI, if any. diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index 7929e6b3..c99cece9 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -91,6 +91,14 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse ), testId('code'), ), + cssPageEntry( + cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'settings'), + cssPageLink(cssPageIcon('Settings'), + cssLinkText(t("Settings")), + urlState().setLinkUrl({docPage: 'settings'}) + ), + testId('settings'), + ), cssSpacer(), dom.maybe(docPageModel.currentDoc, (doc) => { const ex = buildExamples().find(e => e.urlId === doc.urlId); diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index a9a7417a..7a0c0f5a 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -10,7 +10,7 @@ import {Document} from 'app/common/UserAPI'; import clone = require('lodash/clone'); import pickBy = require('lodash/pickBy'); -export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour'); +export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings'); type SpecialDocPage = typeof SpecialDocPage.type; export type IDocPage = number | SpecialDocPage; diff --git a/static/locales/en.client.json b/static/locales/en.client.json index c7e8bf20..424c28f2 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -266,7 +266,10 @@ "Save": "Save", "Save and Reload": "Save and Reload", "This document's ID (for API use):": "This document's ID (for API use):", - "Time Zone:": "Time Zone:" + "Time Zone:": "Time Zone:", + "API": "API", + "Document ID copied to clipboard": "Document ID copied to clipboard", + "Ok": "Ok" }, "DocumentUsage": { "Attachments Size": "Attachments Size", diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index b3465e4f..21c2b7bc 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -260,7 +260,8 @@ "Local currency ({{currency}})": "Devise locale ({{currency}})", "Engine (experimental {{span}} change at own risk):": "Moteur (expérimental {{span}} changez à vos risques et périls):", "Save": "Enregistrer", - "Save and Reload": "Enregistrer et recharger" + "Save and Reload": "Enregistrer et recharger", + "Document ID copied to clipboard": "Identifiant de document copié" }, "DocumentUsage": { "Usage statistics are only available to users with full access to the document data.": "Les statistiques d'utilisation ne sont disponibles qu'aux utilisateurs ayant un accès complet aux données du document.", diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 211d271f..aae76c80 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -2476,8 +2476,8 @@ export async function openProfileSettingsPage() { export async function openDocumentSettings() { await openAccountMenu(); - await driver.findContent('.grist-floating-menu li', 'Document Settings').click(); - await driver.findWait('.test-modal-title', 5000); + await driver.findContent('.grist-floating-menu a', 'Document Settings').click(); + await waitForUrl(/settings/, 5000); } /**