(core) New document settings page

Summary:
This diff implement new page for document settings which replaces the old modal settings.
Diff also adds a new `Settings + API` page item below tools in the left panel that link to that very doc settings page.

Test Plan: Updated existing tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3761
pull/409/head
Cyprien P 1 year ago
parent ca5ae6fa3f
commit a8827b152e

@ -19,6 +19,7 @@ import {EditorMonitor} from "app/client/components/EditorMonitor";
import * as GridView from 'app/client/components/GridView'; import * as GridView from 'app/client/components/GridView';
import {Importer} from 'app/client/components/Importer'; import {Importer} from 'app/client/components/Importer';
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout'; import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; 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 {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory'; import {DocHistory} from 'app/client/ui/DocHistory';
import {startDocTour} from "app/client/ui/DocTour"; import {startDocTour} from "app/client/ui/DocTour";
import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
import {isTourActive} from "app/client/ui/OnBoardingPopups"; import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy'; import {linkFromId, selectBy} from 'app/client/ui/selectBy';
@ -92,7 +92,7 @@ const t = makeT('GristDoc');
const G = getBrowserGlobals('document', 'window'); const G = getBrowserGlobals('document', 'window');
// Re-export some tools to move them from main webpack bundle to the one with GristDoc. // 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 { export interface TabContent {
showObs?: any; showObs?: any;
@ -446,7 +446,7 @@ export class GristDoc extends DisposableWithEvents {
public buildDom() { public buildDom() {
const isMaximized = Computed.create(this, use => use(this.sectionInPopup) !== null); const isMaximized = Computed.create(this, use => use(this.sectionInPopup) !== null);
const isPopup = Computed.create(this, use => { 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 || use(isMaximized) // Layout has a maximized section visible
|| typeof use(this._activeContent) === 'object'; // We are on show raw data popup || 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 === 'code' ? dom.create(CodeEditorPanel, this) :
content === 'acl' ? dom.create(AccessRules, this) : content === 'acl' ? dom.create(AccessRules, this) :
content === 'data' ? dom.create(RawDataPage, this) : content === 'data' ? dom.create(RawDataPage, this) :
content === 'settings' ? dom.create(DocSettingsPage, this) :
content === 'GristDocTour' ? null : content === 'GristDocTour' ? null :
(typeof content === 'object') ? dom.create(owner => { (typeof content === 'object') ? dom.create(owner => {
// In case user changes a page, close the popup. // In case user changes a page, close the popup.

@ -1,4 +1,3 @@
import {loadGristDoc} from 'app/client/lib/imports';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState'; 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[] { private _makeAccountMenu(user: FullUser|null): DomElementArg[] {
const currentOrg = this._appModel.currentOrg; const currentOrg = this._appModel.currentOrg;
const gristDoc = this._docPageModel ? this._docPageModel.gristDoc.get() : null;
// The 'Document Settings' item, when there is an open document. // The 'Document Settings' item, when there is an open document.
const documentSettingsItem = (gristDoc ? const documentSettingsItem = this._docPageModel ? menuItemLink(
menuItem(async () => (await loadGristDoc()).showDocSettingsModal(gristDoc.docInfo, this._docPageModel!), urlState().setLinkUrl({docPage: 'settings'}),
t("Document Settings"), t("Document Settings"),
testId('dm-doc-settings')) : testId('dm-doc-settings')
null); ) : null;
// The item to toggle mobile mode (presence of viewport meta tag). // The item to toggle mobile mode (presence of viewport meta tag).
const mobileModeToggle = menuItem(viewport.toggleViewport, const mobileModeToggle = menuItem(viewport.toggleViewport,

@ -3,110 +3,107 @@
* (new settings to be added here ...). * (new settings to be added here ...).
*/ */
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {dom, IDisposableOwner, styled} from 'grainjs'; import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs';
import {Computed, Observable} from 'grainjs';
import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect"; import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect";
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {ACIndexImpl} from "app/client/lib/ACIndex"; import {ACIndexImpl} from "app/client/lib/ACIndex";
import {loadMomentTimezone} from 'app/client/lib/imports'; import {docListHeader} from "app/client/ui/DocMenuCss";
import {DocInfoRec} from 'app/client/models/DocModel'; import {showTransientTooltip} from 'app/client/ui/tooltips';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {testId, vars} from 'app/client/ui2018/cssVars';
import {select} from 'app/client/ui2018/menus'; import {select} from 'app/client/ui2018/menus';
import {saveModal} from 'app/client/ui2018/modals';
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete'; import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
import {EngineCode} from 'app/common/DocumentSettings'; import {EngineCode} from 'app/common/DocumentSettings';
import {GristLoadConfig} from 'app/common/gristUrls'; import {GristLoadConfig} from 'app/common/gristUrls';
import {propertyCompare} from "app/common/gutil"; import {propertyCompare} from "app/common/gutil";
import {getCurrency, locales} from "app/common/Locales"; 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'); const t = makeT('DocumentSettings');
/** export class DocSettingsPage extends Disposable {
* Builds a simple saveModal for saving settings. private _docInfo = this._gristDoc.docInfo;
*/
export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: DocPageModel): Promise<void> { private _timezone = this._docInfo.timezone;
const moment = await loadMomentTimezone(); private _locale: KoSaveableObservable<string> = this._docInfo.documentSettingsJson.prop('locale');
return saveModal((ctl, owner) => { private _currency: KoSaveableObservable<string|undefined> = this._docInfo.documentSettingsJson.prop('currency');
const timezoneObs = Observable.create(owner, docInfo.timezone.peek()); private _engine: Computed<EngineCode|undefined> = Computed.create(this, (
use => use(this._docInfo.documentSettingsJson.prop('engine'))
))
.onWrite(val => this._setEngine(val));
const docSettings = docInfo.documentSettingsJson.peek(); constructor(private _gristDoc: GristDoc) {
const {locale, currency, engine} = docSettings; super();
const localeObs = Observable.create(owner, locale); }
const currencyObs = Observable.create(owner, currency);
const engineObs = Observable.create(owner, engine);
// Check if server supports engine choices - if so, we will allow user to pick. public buildDom() {
const canChangeEngine = getSupportedEngineChoices().length > 0; const canChangeEngine = getSupportedEngineChoices().length > 0;
const docPageModel = this._gristDoc.docPageModel;
return { return cssContainer(
title: t("Document Settings"), cssHeader(t('Document Settings')),
body: [ cssDataRow(t("Time Zone:")),
cssDataRow(t("This document's ID (for API use):")), cssDataRow(
cssDataRow(dom('tt', docPageModel.currentDocId.get())), dom.create(buildTZAutocomplete, moment, fromKo(this._timezone), (val) => this._timezone.saveOnly(val))
cssDataRow(t("Time Zone:")), ),
cssDataRow(dom.create(buildTZAutocomplete, moment, timezoneObs, (val) => timezoneObs.set(val))), cssDataRow(t("Locale:")),
cssDataRow(t("Locale:")), cssDataRow(dom.create(buildLocaleSelect, this._locale)),
cssDataRow(dom.create(buildLocaleSelect, localeObs)), cssDataRow(t("Currency:")),
cssDataRow(t("Currency:")), cssDataRow(dom.domComputed(fromKo(this._locale), (l) =>
cssDataRow(dom.domComputed(localeObs, (l) => dom.create(buildCurrencyPicker, fromKo(this._currency), (val) => this._currency.saveOnly(val),
dom.create(buildCurrencyPicker, currencyObs, (val) => currencyObs.set(val), {defaultCurrencyLabel: t("Local currency ({{currency}})", {currency: getCurrency(l)})})
{defaultCurrencyLabel: t("Local currency ({{currency}})", {currency: getCurrency(l)})}) )),
)), canChangeEngine ? [
canChangeEngine ? [ // Small easter egg: you can click on the skull-and-crossbones to
// Small easter egg: you can click on the skull-and-crossbones to // force a reload of the document.
// force a reload of the document. cssDataRow(t("Engine (experimental {{span}} change at own risk):", {span:
cssDataRow(t("Engine (experimental {{span}} change at own risk):", {span: dom('span', '☠',
dom('span', '☠', dom.style('cursor', 'pointer'),
dom.style('cursor', 'pointer'), dom.on('click', async () => {
dom.on('click', async () => { await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); document.location.reload();
document.location.reload(); }))
}))
})), })),
select(engineObs, getSupportedEngineChoices()), select(this._engine, getSupportedEngineChoices()),
] : null, ] : null,
], cssHeader(t('API')),
// Modal label is "Save", unless engine is changed. If engine is changed, the document will cssDataRow(t("This document's ID (for API use):")),
// need a reload to switch engines, so we replace the label with "Save and Reload". cssDataRow(cssHoverWrapper(
saveLabel: dom.text((use) => (use(engineObs) === docSettings.engine) ? t("Save") : t("Save and Reload")), dom('tt', docPageModel.currentDocId.get()),
saveFunc: async () => { dom.on('click', async (e, d) => {
await docInfo.updateColValues({ e.stopImmediatePropagation();
timezone: timezoneObs.get(), e.preventDefault();
documentSettings: JSON.stringify({ showTransientTooltip(d, t("Document ID copied to clipboard"), {
...docInfo.documentSettingsJson.peek(), key: 'copy-document-id'
locale: localeObs.get(), });
currency: currencyObs.get(), await copyToClipboard(docPageModel.currentDocId.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
);
})
};
});
}
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}; type LocaleItem = ACSelectItem & {locale?: string};
function buildLocaleSelect( function buildLocaleSelect(
owner: IDisposableOwner, owner: IDisposableOwner,
locale: Observable<string> locale: KoSaveableObservable<string>,
) { ) {
const localeList: LocaleItem[] = locales.map(l => ({ const localeList: LocaleItem[] = locales.map(l => ({
value: l.name, // Use name as a value, we will translate the name into the locale on save 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. // 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 // To show the label - create another observable that will be in sync with the value, but
// will contain text. // will contain text.
const localeCode = locale.get(); const textObs = Computed.create(owner, use => {
const localeName = locales.find(l => l.code === localeCode)?.name || localeCode; const localeCode = use(locale);
const textObs = Observable.create(owner, localeName); const localeName = locales.find(l => l.code === localeCode)?.name || localeCode;
return localeName;
});
return buildACSelect(owner, return buildACSelect(owner,
{ {
acIndex, valueObs: textObs, acIndex, valueObs: textObs,
save(value, item: LocaleItem | undefined) { save(_value, item: LocaleItem | undefined) {
if (!item) { throw new Error("Invalid locale"); } if (!item) { throw new Error("Invalid locale"); }
textObs.set(value); locale.saveOnly(item.locale!).catch(reportError);
locale.set(item.locale!);
}, },
}, },
testId("locale-autocomplete") 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. // This matches the style used in showProfileModal in app/client/ui/AccountWidget.
const cssDataRow = styled('div', ` const cssDataRow = styled('div', `
margin: 16px 0px; margin: 16px 0px;
font-size: ${vars.largeFontSize}; font-size: ${vars.largeFontSize};
color: ${theme.text};
`); `);
// Check which engines can be selected in the UI, if any. // Check which engines can be selected in the UI, if any.

@ -91,6 +91,14 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
), ),
testId('code'), testId('code'),
), ),
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'settings'),
cssPageLink(cssPageIcon('Settings'),
cssLinkText(t("Settings")),
urlState().setLinkUrl({docPage: 'settings'})
),
testId('settings'),
),
cssSpacer(), cssSpacer(),
dom.maybe(docPageModel.currentDoc, (doc) => { dom.maybe(docPageModel.currentDoc, (doc) => {
const ex = buildExamples().find(e => e.urlId === doc.urlId); const ex = buildExamples().find(e => e.urlId === doc.urlId);

@ -10,7 +10,7 @@ import {Document} from 'app/common/UserAPI';
import clone = require('lodash/clone'); import clone = require('lodash/clone');
import pickBy = require('lodash/pickBy'); 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; type SpecialDocPage = typeof SpecialDocPage.type;
export type IDocPage = number | SpecialDocPage; export type IDocPage = number | SpecialDocPage;

@ -266,7 +266,10 @@
"Save": "Save", "Save": "Save",
"Save and Reload": "Save and Reload", "Save and Reload": "Save and Reload",
"This document's ID (for API use):": "This document's ID (for API use):", "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": { "DocumentUsage": {
"Attachments Size": "Attachments Size", "Attachments Size": "Attachments Size",

@ -260,7 +260,8 @@
"Local currency ({{currency}})": "Devise locale ({{currency}})", "Local currency ({{currency}})": "Devise locale ({{currency}})",
"Engine (experimental {{span}} change at own risk):": "Moteur (expérimental {{span}} changez à vos risques et périls):", "Engine (experimental {{span}} change at own risk):": "Moteur (expérimental {{span}} changez à vos risques et périls):",
"Save": "Enregistrer", "Save": "Enregistrer",
"Save and Reload": "Enregistrer et recharger" "Save and Reload": "Enregistrer et recharger",
"Document ID copied to clipboard": "Identifiant de document copié"
}, },
"DocumentUsage": { "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.", "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.",

@ -2476,8 +2476,8 @@ export async function openProfileSettingsPage() {
export async function openDocumentSettings() { export async function openDocumentSettings() {
await openAccountMenu(); await openAccountMenu();
await driver.findContent('.grist-floating-menu li', 'Document Settings').click(); await driver.findContent('.grist-floating-menu a', 'Document Settings').click();
await driver.findWait('.test-modal-title', 5000); await waitForUrl(/settings/, 5000);
} }
/** /**

Loading…
Cancel
Save