Add appearance parameter to override theme preferences (#620)

This commit is contained in:
Philip Standt 2023-08-15 19:29:29 +02:00 committed by GitHub
parent f1a0b61e15
commit 9df62e3d81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 113 additions and 26 deletions

View File

@ -355,7 +355,7 @@ export class GristDoc extends DisposableWithEvents {
this.autoDispose(subscribe(urlState().state, async (_use, state) => { this.autoDispose(subscribe(urlState().state, async (_use, state) => {
// Only start a tour or tutorial when the full interface is showing, i.e. not when in // Only start a tour or tutorial when the full interface is showing, i.e. not when in
// embedded mode. // embedded mode.
if (state.params?.style === 'light') { if (state.params?.style === 'singlePage') {
return; return;
} }

View File

@ -19,7 +19,7 @@ import {LocalPlugin} from 'app/common/plugin';
import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs'; import {DismissedPopup, DismissedReminder, UserPrefs} from 'app/common/Prefs';
import {isOwner, isOwnerOrEditor} from 'app/common/roles'; import {isOwner, isOwnerOrEditor} from 'app/common/roles';
import {getTagManagerScript} from 'app/common/tagManager'; import {getTagManagerScript} from 'app/common/tagManager';
import {getDefaultThemePrefs, Theme, ThemeAppearance, ThemeColors, ThemePrefs, import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs,
ThemePrefsChecker} from 'app/common/ThemePrefs'; ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes'; import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils'; import {getGristConfig} from 'app/common/urlUtils';
@ -450,14 +450,26 @@ export class AppModelImpl extends Disposable implements AppModel {
private _getCurrentThemeObs() { private _getCurrentThemeObs() {
return Computed.create(this, this.themePrefs, prefersDarkModeObs(), return Computed.create(this, this.themePrefs, prefersDarkModeObs(),
(_use, themePrefs, prefersDarkMode) => { (_use, themePrefs, prefersDarkMode) => {
let appearance: ThemeAppearance; let {appearance, syncWithOS} = themePrefs;
if (!themePrefs.syncWithOS) {
appearance = themePrefs.appearance; const urlParams = urlState().state.get().params;
} else { if (urlParams?.themeAppearance) {
appearance = urlParams?.themeAppearance;
}
if (urlParams?.themeSyncWithOs !== undefined) {
syncWithOS = urlParams?.themeSyncWithOs;
}
if (syncWithOS) {
appearance = prefersDarkMode ? 'dark' : 'light'; appearance = prefersDarkMode ? 'dark' : 'light';
} }
const nameOrColors = themePrefs.colors[appearance]; let nameOrColors = themePrefs.colors[appearance];
if (urlParams?.themeName) {
nameOrColors = urlParams?.themeName;
}
let colors: ThemeColors; let colors: ThemeColors;
if (typeof nameOrColors === 'string') { if (typeof nameOrColors === 'string') {
colors = getThemeColors(nameOrColors); colors = getThemeColors(nameOrColors);

View File

@ -392,7 +392,7 @@ const cssPageContainer = styled(cssVBox, `
padding-bottom: ${bottomFooterHeightPx}px; padding-bottom: ${bottomFooterHeightPx}px;
min-width: 240px; min-width: 240px;
} }
.interface-light & { .interface-singlePage & {
padding-bottom: 0; padding-bottom: 0;
} }
} }
@ -434,7 +434,7 @@ export const cssLeftPane = styled(cssVBox, `
display: none; display: none;
} }
} }
.interface-light & { .interface-singlePage & {
display: none; display: none;
} }
&-overlap { &-overlap {
@ -501,7 +501,7 @@ const cssRightPane = styled(cssVBox, `
display: none; display: none;
} }
} }
.interface-light & { .interface-singlePage & {
display: none; display: none;
} }
`); `);
@ -519,7 +519,7 @@ const cssHeader = styled('div', `
} }
} }
.interface-light & { .interface-singlePage & {
display: none; display: none;
} }
`); `);
@ -556,7 +556,7 @@ const cssBottomFooter = styled ('div', `
display: none; display: none;
} }
} }
.interface-light & { .interface-singlePage & {
display: none; display: none;
} }
`); `);

View File

@ -36,7 +36,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
]; ];
const viewRec = viewSection.view(); const viewRec = viewSection.view();
const isLight = urlState().state.get().params?.style === 'light'; const isSinglePage = urlState().state.get().params?.style === 'singlePage';
const sectionId = viewSection.table.peek().rawViewSectionRef.peek(); const sectionId = viewSection.table.peek().rawViewSectionRef.peek();
const anchorUrlState = viewInstance.getAnchorLinkForSection(sectionId); const anchorUrlState = viewInstance.getAnchorLinkForSection(sectionId);
@ -57,7 +57,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
const showRawData = (use: UseCB) => { const showRawData = (use: UseCB) => {
return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data. return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data.
&& !isLight // Don't show raw data in light mode. && !isSinglePage // Don't show raw data in single page mode.
; ;
}; };
@ -84,7 +84,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
menuItemCmd(allCommands.editLayout, t("Edit Card Layout"), menuItemCmd(allCommands.editLayout, t("Edit Card Layout"),
dom.cls('disabled', isReadonly))), dom.cls('disabled', isReadonly))),
dom.maybe(!isLight, () => [ dom.maybe(!isSinglePage, () => [
menuDivider(), menuDivider(),
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')), menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter")), menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter")),
@ -111,12 +111,12 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
*/ */
export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: GristDoc) { export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: GristDoc) {
const isReadonly = gristDoc.isReadonly.get(); const isReadonly = gristDoc.isReadonly.get();
const isLight = urlState().state.get().params?.style === 'light'; const isSinglePage = urlState().state.get().params?.style === 'singlePage';
const sectionId = viewSection.table.peek().rawViewSectionRef.peek(); const sectionId = viewSection.table.peek().rawViewSectionRef.peek();
const anchorUrlState = { hash: { sectionId, popup: true } }; const anchorUrlState = { hash: { sectionId, popup: true } };
const rawUrl = urlState().makeUrl(anchorUrlState); const rawUrl = urlState().makeUrl(anchorUrlState);
return [ return [
dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.maximizedSectionId), dom.maybe((use) => !use(viewSection.isRaw) && !isSinglePage && !use(gristDoc.maximizedSectionId),
() => menuItemLink( () => menuItemLink(
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'), { href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
dom.on('click', (ev) => { dom.on('click', (ev) => {

View File

@ -526,6 +526,8 @@ export interface ThemeColors {
} }
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>; export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;
export const ThemeAppearanceChecker = createCheckers(ThemePrefsTI).ThemeAppearance as CheckerT<ThemeAppearance>;
export const ThemeNameChecker = createCheckers(ThemePrefsTI).ThemeName as CheckerT<ThemeName>;
export function getDefaultThemePrefs(): ThemePrefs { export function getDefaultThemePrefs(): ThemePrefs {
return { return {

View File

@ -10,6 +10,7 @@ import {getGristConfig} from 'app/common/urlUtils';
import {Document} from 'app/common/UserAPI'; 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');
import {ThemeAppearance, ThemeAppearanceChecker, ThemeName, ThemeNameChecker} from './ThemePrefs';
export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings', 'webhook'); export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings', 'webhook');
type SpecialDocPage = typeof SpecialDocPage.type; type SpecialDocPage = typeof SpecialDocPage.type;
@ -44,8 +45,8 @@ export type LoginPage = typeof LoginPage.type;
export const SupportGristPage = StringUnion('support-grist'); export const SupportGristPage = StringUnion('support-grist');
export type SupportGristPage = typeof SupportGristPage.type; export type SupportGristPage = typeof SupportGristPage.type;
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience. // Overall UI style. "full" is normal, "singlePage" is a single page focused, panels hidden experience.
export const InterfaceStyle = StringUnion('light', 'full'); export const InterfaceStyle = StringUnion('singlePage', 'full');
export type InterfaceStyle = typeof InterfaceStyle.type; export type InterfaceStyle = typeof InterfaceStyle.type;
// Default subdomain for home api service if not otherwise specified. // Default subdomain for home api service if not otherwise specified.
@ -126,6 +127,9 @@ export interface IGristUrlState {
compare?: string; compare?: string;
linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs. linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs.
// Encoded in URL as query params with extra '_' suffix. // Encoded in URL as query params with extra '_' suffix.
themeSyncWithOs?: boolean;
themeAppearance?: ThemeAppearance;
themeName?: ThemeName;
}; };
hash?: HashLink; // if present, this specifies an individual row within a section of a page. hash?: HashLink; // if present, this specifies an individual row within a section of a page.
} }
@ -392,15 +396,40 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
} }
if (sp.has('style')) { if (sp.has('style')) {
state.params!.style = InterfaceStyle.parse(sp.get('style')); let style = sp.get('style');
if (style === 'light') {
style = 'singlePage';
}
state.params!.style = InterfaceStyle.parse(style);
} }
if (sp.has('embed')) { if (sp.has('embed')) {
const embed = state.params!.embed = isAffirmative(sp.get('embed')); const embed = state.params!.embed = isAffirmative(sp.get('embed'));
// Turn view mode on if no mode has been specified, and not a fork. // Turn view mode on if no mode has been specified, and not a fork.
if (embed && !state.mode && !state.fork) { state.mode = 'view'; } if (embed && !state.mode && !state.fork) { state.mode = 'view'; }
// Turn on light style if no style has been specified. // Turn on single page style if no style has been specified.
if (embed && !state.params!.style) { state.params!.style = 'light'; } if (embed && !state.params!.style) { state.params!.style = 'singlePage'; }
} }
// Theme overrides
if (sp.has('themeSyncWithOs')) {
state.params!.themeSyncWithOs = isAffirmative(sp.get('themeSyncWithOs'));
}
if (sp.has('themeAppearance')) {
const appearance = sp.get('themeAppearance');
if (ThemeAppearanceChecker.strictTest(appearance)) {
state.params!.themeAppearance = appearance;
}
}
if (sp.has('themeName')) {
const themeName = sp.get('themeName');
if (ThemeNameChecker.strictTest(themeName)) {
state.params!.themeName = themeName;
}
}
if (sp.has('compare')) { if (sp.has('compare')) {
state.params!.compare = sp.get('compare')!; state.params!.compare = sp.get('compare')!;
} }

View File

@ -287,8 +287,8 @@ describe('gristUrlState', function() {
it('should support an update function to pushUrl and makeUrl', async function() { it('should support an update function to pushUrl and makeUrl', async function() {
mockWindow.location = new URL('https://bar.example.com/doc/DOC/p/5') as unknown as Location; mockWindow.location = new URL('https://bar.example.com/doc/DOC/p/5') as unknown as Location;
const state = UrlState.create(null, mockWindow, prod) as UrlState<IGristUrlState>; const state = UrlState.create(null, mockWindow, prod) as UrlState<IGristUrlState>;
await state.pushUrl({params: {style: 'light', linkParameters: {foo: 'A', bar: 'B'}}}); await state.pushUrl({params: {style: 'singlePage', linkParameters: {foo: 'A', bar: 'B'}}});
assert.equal(mockWindow.location.href, 'https://bar.example.com/doc/DOC/p/5?style=light&foo_=A&bar_=B'); assert.equal(mockWindow.location.href, 'https://bar.example.com/doc/DOC/p/5?style=singlePage&foo_=A&bar_=B');
state.loadState(); // changing linkParameters requires a page reload state.loadState(); // changing linkParameters requires a page reload
assert.equal(state.makeUrl((prevState) => merge({}, prevState, {params: {style: 'full'}})), assert.equal(state.makeUrl((prevState) => merge({}, prevState, {params: {style: 'full'}})),
'https://bar.example.com/doc/DOC/p/5?style=full&foo_=A&bar_=B'); 'https://bar.example.com/doc/DOC/p/5?style=full&foo_=A&bar_=B');

View File

@ -1,8 +1,52 @@
import {parseFirstUrlPart} from 'app/common/gristUrls'; import {decodeUrl, IGristUrlState, parseFirstUrlPart} from 'app/common/gristUrls';
import {assert} from 'chai'; import {assert} from 'chai';
describe('gristUrls', function() { describe('gristUrls', function() {
function assertUrlDecode(url: string, expected: Partial<IGristUrlState>) {
const actual = decodeUrl({}, new URL(url));
for (const property in expected) {
const expectedValue = expected[property as keyof IGristUrlState];
const actualValue = actual[property as keyof IGristUrlState];
assert.deepEqual(actualValue, expectedValue);
}
}
describe('encodeUrl', function() {
it('should detect theme appearance override', function() {
assertUrlDecode(
'http://localhost/?themeAppearance=light',
{params: {themeAppearance: 'light'}},
);
assertUrlDecode(
'http://localhost/?themeAppearance=dark',
{params: {themeAppearance: 'dark'}},
);
});
it('should detect theme sync with os override', function() {
assertUrlDecode(
'http://localhost/?themeSyncWithOs=true',
{params: {themeSyncWithOs: true}},
);
});
it('should detect theme name override', function() {
assertUrlDecode(
'http://localhost/?themeName=GristLight',
{params: {themeName: 'GristLight'}},
);
assertUrlDecode(
'http://localhost/?themeName=GristDark',
{params: {themeName: 'GristDark'}},
);
});
});
describe('parseFirstUrlPart', function() { describe('parseFirstUrlPart', function() {
it('should strip out matching tag', function() { it('should strip out matching tag', function() {
assert.deepEqual(parseFirstUrlPart('o', '/o/foo/bar?x#y'), {value: 'foo', path: '/bar?x#y'}); assert.deepEqual(parseFirstUrlPart('o', '/o/foo/bar?x#y'), {value: 'foo', path: '/bar?x#y'});

View File

@ -675,7 +675,7 @@ async function openMenu(tableId: string) {
} }
async function waitForRawData() { async function waitForRawData() {
await driver.findWait('.test-raw-data-list', 1000); await driver.findWait('.test-raw-data-list', 2000);
await gu.waitForServer(); await gu.waitForServer();
} }