(core) New look for site switcher in top-left corner, with support for per-org logos

Summary:
  - Site switcher will show initials (either from user's name or team name),
  - Anonymous users see a grist logo on personal site, but team logo (or initials) on team site,
  - Admin pages (and other pages without orgs) show grist logo,
  - Custom image can be switched on the billing page, common formats are supported up to 100KB.
  - Larger images are down-scaled (on the front-end)
  - SVG larger than 100KB are not accepted
  - Files are stored as data URL's in org prefs,

Test Plan: Added new tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4341
This commit is contained in:
Jarosław Sadziński 2024-10-07 16:54:03 +02:00
parent e8f9da9b5c
commit 0bdc838975
15 changed files with 267 additions and 91 deletions

View File

@ -64,3 +64,25 @@ export function stopEvent(ev: Event) {
ev.preventDefault();
ev.stopImmediatePropagation();
}
/**
* Adds a handler for a custom event triggered by `domDispatch` function below.
*/
export function domOnCustom(name: string, handler: (args: any, event: Event, element: Element) => void) {
return (el: Element) => {
dom.onElem(el, name, (ev, target) => {
const cv = ev as CustomEvent;
handler(cv.detail, ev, target);
});
};
}
/**
* Triggers a custom event on an element.
*/
export function domDispatch(element: Element, name: string, args?: any) {
element.dispatchEvent(new CustomEvent(name, {
bubbles: true,
detail: args
}));
}

View File

@ -48,13 +48,18 @@ export async function selectFiles(options: SelectFileOptions,
if (typeof electronSelectFiles === 'function') {
result = await electronSelectFiles(getElectronOptions(options));
} else {
const files: File[] = await openFilePicker(getFileDialogOptions(options));
result = await uploadFiles(files, options, onProgress);
result = await uploadFiles(await selectPicker(options), options, onProgress);
}
onProgress(100);
return result;
}
export async function selectPicker(options: SelectFileOptions) {
const files: File[] = await openFilePicker(getFileDialogOptions(options));
return files;
}
// Helper to convert SelectFileOptions to the browser's FileDialogOptions.
function getFileDialogOptions(options: SelectFileOptions): FileDialogOptions {
const resOptions: FileDialogOptions = {};

View File

@ -1,7 +1,7 @@
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {getTheme} from 'app/client/ui/CustomThemes';
import {cssLeftPane} from 'app/client/ui/PagePanels';
import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
import * as version from 'app/common/version';
import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
import {commonUrls} from 'app/common/gristUrls';
@ -15,8 +15,11 @@ import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
import {Computed, Disposable, dom, DomContents, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
import {getGristConfig} from 'app/common/urlUtils';
import {makeTestId} from 'app/client/lib/domUtils';
import {createTeamImage, createUserImage, cssUserImage} from 'app/client/ui/UserImage';
const t = makeT('AppHeader');
const testId = makeTestId('test-dm-');
// Maps a name of a Product (from app/gen-server/entity/Product.ts) to a tag (pill) to show next
// to the org name.
@ -68,26 +71,63 @@ export class AppHeader extends Disposable {
private _appLogoOrgLink = Computed.create(this, this._appLogoOrg, (_use, {link}) => link);
constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) {
constructor(
private _appModel: AppModel,
private _docPageModel?: DocPageModel|null) {
super();
}
public buildDom() {
// Check if we have a custom image.
const customImage = this._appModel.currentOrg?.orgPrefs?.customLogoUrl;
const variant = () => [cssUserImage.cls('-border'), cssUserImage.cls('-square')];
// Personal avatar is shown only for logged in users.
const personalAvatar = () => !this._appModel.currentValidUser
? cssAppLogo.cls('-grist-logo')
: createUserImage(this._appModel.currentValidUser, 'medium', variant());
// Team avatar is shown only for team sites (even for anonymous users).
const teamAvatar = () => !this._currentOrg
? cssAppLogo.cls('-grist-logo')
: createTeamImage(this._currentOrg, 'medium', variant());
// Depending on site the avatar is either personal or team.
const avatar = () => this._appModel.isPersonal
? personalAvatar()
: teamAvatar();
// Show the image if it's set, otherwise show the avatar.
const image = () => customImage
? dom.style('background-image', customImage ? `url(${customImage})` : '')
: avatar();
// Maybe we should show custom logo and make it wide (without site switcher).
const productFlavor = getTheme(this._appModel.topAppModel.productFlavor);
const content = () => productFlavor.wideLogo
? null
: image();
const title = `Version ${version.version}` +
((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '');
return cssAppHeader(
cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false),
dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo(
// Show version when hovering over the application icon.
// Include gitcommit when known. Cast version.gitcommit since, depending
// on how Grist is compiled, tsc may believe it to be a constant and
// believe that testing it is unnecessary.
{title: `Version ${version.version}` +
((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')},
this._setHomePageUrl(orgLink),
testId('dm-logo')
)),
this._buildOrgLinkOrMenu(),
cssAppHeaderBox(
dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo(
// Show version when hovering over the application icon.
// Include gitcommit when known. Cast version.gitcommit since, depending
// on how Grist is compiled, tsc may believe it to be a constant and
// believe that testing it is unnecessary.
{title},
this._setHomePageUrl(orgLink),
content(),
testId('logo'),
)),
this._buildOrgLinkOrMenu(),
),
);
}
@ -97,15 +137,18 @@ export class AppHeader extends Disposable {
if (deploymentType === 'saas' && !currentValidUser && isTemplatesSite) {
// When signed out and on the templates site (in SaaS Grist), link to the templates page.
return cssOrgLink(
cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')),
cssOrgName(dom.text(this._appLogoOrgName), testId('orgname')),
{href: commonUrls.templates},
testId('dm-org'),
testId('org'),
);
} else {
return cssOrg(
cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')),
cssOrgName(dom.text(this._appLogoOrgName), testId('orgname')),
productPill(this._currentOrg),
dom.maybe(this._appLogoOrgName, () => cssDropdownIcon('Dropdown')),
dom.maybe(this._appLogoOrgName, () => [
cssSpacer(),
cssDropdownIcon('Dropdown'),
]),
menu(() => [
menuSubHeader(
this._appModel.isPersonal
@ -128,7 +171,7 @@ export class AppHeader extends Disposable {
maybeAddSiteSwitcherSection(this._appModel),
], { placement: 'bottom-start' }),
testId('dm-org'),
testId('org'),
);
}
}
@ -232,16 +275,22 @@ export function productPill(org: Organization|null, options: {large?: boolean} =
return cssProductPill(cssProductPill.cls('-' + pillTag),
options.large ? cssProductPill.cls('-large') : null,
pillTag,
testId('appheader-product-pill'));
testId('product-pill'));
}
const cssAppHeader = styled('div', `
display: flex;
const cssAppHeader = styled('div._cssAppHeader', `
width: 100%;
height: 100%;
align-items: center;
background-color: ${theme.leftPanelBg};
padding: 0px;
padding: 8px;
.${cssLeftPane.className}-open & {
padding: 8px 16px;
}
&-widelogo {
padding: 0px !important;
}
&, &:hover, &:focus {
text-decoration: none;
outline: none;
@ -249,23 +298,54 @@ const cssAppHeader = styled('div', `
}
`);
const cssAppLogo = styled('a', `
const cssAppHeaderBox = styled('div._cssAppHeaderBox', `
display: flex;
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
background-color: ${theme.appHeaderBg};
border: 1px solid ${theme.appHeaderBorder};
border-radius: 4px;
&:hover {
background-color: ${theme.appHeaderHoverBg};
}
.${cssAppHeader.className}-widelogo & {
border: none !important;
overflow: visible;
}
`);
const cssAppLogo = styled('a._cssAppLogo', `
flex: none;
height: 48px;
width: 48px;
background-image: var(--icon-GristLogo);
background-size: ${vars.logoSize};
height: 100%;
aspect-ratio: 1 / 1;
text-decoration: none;
background-repeat: no-repeat;
background-position: center;
background-color: ${vars.logoBg};
background-color: inherit;
background-size: cover;
&-grist-logo {
background-image: var(--icon-GristLogo);
background-color: ${vars.logoBg};
background-size: ${vars.logoSize};
}
.${cssAppHeader.className}-widelogo & {
width: 100%;
background-size: contain;
background-origin: content-box;
padding: 8px;
border-right: none !important;
background-size: contain;
}
.${cssLeftPane.className}-open .${cssAppHeader.className}-widelogo & {
background-image: var(--icon-GristWideLogo, var(--icon-GristLogo));
background-size: contain;
}
&:hover {
text-decoration: none;
}
`);
@ -275,25 +355,28 @@ const cssDropdownIcon = styled(icon, `
margin-right: 8px;
`);
const cssOrg = styled('div', `
const cssSpacer = styled('div', `
display: none;
flex: 1;
display: block;
`);
const cssOrg = styled('div._cssOrg', `
display: none;
flex-grow: 1;
flex-basis: 0px;
overflow: hidden;
align-items: center;
max-width: calc(100% - 48px);
cursor: pointer;
height: 100%;
font-weight: 500;
&:hover {
background-color: ${theme.hover};
}
.${cssLeftPane.className}-open & {
display: flex;
border-left: 1px solid ${theme.appHeaderBorder};
}
`);
const cssOrgLink = styled('a', `
const cssOrgLink = styled('a.cssOrgLink', `
display: none;
flex-grow: 1;
align-items: center;
@ -308,6 +391,10 @@ const cssOrgLink = styled('a', `
text-decoration: none;
}
.${cssLeftPane.className}-open & {
border-left: 1px solid ${theme.appHeaderBorder};
}
&:hover {
color: ${theme.text};
background-color: ${theme.hover};

View File

@ -1,6 +1,18 @@
import {GristLoadConfig} from 'app/common/gristUrls';
// TODO: document this all, no tests are exercising this code.
import {getGristConfig} from 'app/common/urlUtils';
import {styled} from 'grainjs';
/**
* Is this grist installation or someone's modified installation. We allow modifying logo
* at the right corner, and making it wider (removing site switcher in the process).
*
* If fieldLink, shows wide logo and hides the switcher, otherwise shows the regular logo.
*
* We can convert any org name to a ProductFlavor and any ProductFlavor to a CustomTheme.
*
* TODO: explain what is fieldlink, I think this is an user of custom Grist build.
*/
export type ProductFlavor = 'grist' | 'fieldlink';
export interface CustomTheme {
@ -14,13 +26,15 @@ export function getFlavor(org?: string): ProductFlavor {
const themeOrg = new URLSearchParams(window.location.search).get('__themeOrg');
if (themeOrg) { org = themeOrg; }
if (!org) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
org = gristConfig && gristConfig.org;
}
// If still not set, use the org from the config.
org ||= getGristConfig()?.org;
// If the org is 'fieldlink', use the fieldlink flavor.
if (org === 'fieldlink') {
return 'fieldlink';
}
// For any other situation, use the grist flavor.
return 'grist';
}

View File

@ -5,35 +5,60 @@ import {icon} from 'app/client/ui2018/icons';
export type Size = 'small' | 'medium' | 'large';
interface OrgProperties {
name: string;
domain: string|null;
}
/** Helper wrapper around OrgProfile that converts it to UserProfile */
function OrgUser(org: OrgProperties): UserProfile {
return {name: org.name, email: org.domain || ''};
}
/**
* Returns a DOM element showing a circular icon with a user's picture, or the user's initials if
* picture is missing. Also varies the color of the circle when using initials.
*/
export function createUserImage(
user: UserProfile|'exampleUser'|null, size: Size, ...args: DomElementArg[]
user: Partial<UserProfile>|'exampleUser'|null, size: Size, ...args: DomElementArg[]
): HTMLElement {
let initials: string;
return cssUserImage(
cssUserImage.cls('-' + size),
(user === 'exampleUser') ? [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')] :
(!user || user.anonymous) ? cssUserImage.cls('-anon') :
[
(user.picture ? cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))) : null),
dom.style('background-color', pickColor(user)),
(initials = getInitials(user)).length > 1 ? cssUserImage.cls('-reduced') : null,
initials!,
],
...(function*() {
if (user === 'exampleUser') {
yield [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')];
} else if (!user || user.anonymous) {
yield cssUserImage.cls('-anon');
} else {
if (user.picture) {
yield cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true)));
}
yield dom.style('background-color', pickColor(user));
const initials = getInitials(user);
if (initials.length > 1) {
yield cssUserImage.cls('-reduced');
}
yield initials;
}
})(),
...args,
);
}
/**
* Returns a DOM element showing team's initials as a circular icon.
*/
export function createTeamImage(org: OrgProperties, size: Size, ...args: DomElementArg[]): HTMLElement {
return createUserImage(OrgUser(org), size, ...args);
}
/**
* Extracts initials from a user, e.g. a FullUser. E.g. "Foo Bar" is turned into "FB", and
* "foo@example.com" into just "f".
*
* Exported for testing.
*/
export function getInitials(user: {name?: string, email?: string}) {
export function getInitials(user: Partial<UserProfile>) {
const source = (user.name && user.name.trim()) || (user.email && user.email.trim()) || '';
return source.split(/\s+/, 2).map(p => p.slice(0, 1)).join('');
}
@ -41,7 +66,7 @@ export function getInitials(user: {name?: string, email?: string}) {
/**
* Hashes the username to return a color.
*/
function pickColor(user: UserProfile): string {
function pickColor(user: Partial<UserProfile>): string {
let c = hashCode(user.name + ':' + user.email) % someColors.length;
if (c < 0) { c += someColors.length; }
return someColors[c];
@ -87,22 +112,26 @@ export const cssUserImage = styled('div', `
display: flex;
align-items: center;
justify-content: center;
--border-size: 0px;
width: calc(var(--icon-size, 24px) - var(--border-size));
height: calc(var(--icon-size, 24px) - var(--border-size));
line-height: 1em;
&-small {
width: 24px;
height: 24px;
--icon-size: 24px;
font-size: 13.5px;
--reduced-font-size: 12px;
}
&-medium {
width: 32px;
height: 32px;
--icon-size: 32px;
font-size: 18px;
--reduced-font-size: 16px;
}
&-border {
--border-size: 2px;
}
&-large {
width: 40px;
height: 40px;
--icon-size: 40px;
font-size: 22.5px;
--reduced-font-size: 20px;
}
@ -117,7 +146,9 @@ export const cssUserImage = styled('div', `
&-reduced {
font-size: var(--reduced-font-size);
}
&-square {
border-radius: 0px;
}
&-example {
background-color: ${colors.slate};
border: 1px solid ${colors.slate};
@ -130,7 +161,7 @@ const cssUserPicture = styled('img', `
height: 100%;
object-fit: cover;
background-color: ${theme.menuBg};
border-radius: 100px;
border-radius: inherit;
box-sizing: content-box; /* keep the border outside of the size of the image */
`);

View File

@ -903,6 +903,11 @@ export const theme = {
colors.mediumGreyOpaque),
markdownCellMediumBorder: new CustomProp('theme-markdown-cell-medium-border', undefined,
colors.darkGrey),
/* App Header */
appHeaderBg: new CustomProp('theme-app-header-bg', undefined, colors.light),
appHeaderBorder: new CustomProp('theme-app-header-border', undefined, colors.mediumGreyOpaque),
appHeaderHoverBg: new CustomProp('theme-app-header-hover-bg', undefined, colors.hover),
};
const cssColors = values(colors).map(v => v.decl()).join('\n');

View File

@ -1,11 +1,14 @@
import {createPopper, Options as PopperOptions} from '@popperjs/core';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {autoFocus, domDispatch, domOnCustom} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {makeT} from 'app/client/lib/localization';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {CellRec, ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {RowSource, RowWatcher} from 'app/client/models/rowset';
import {autoGrow} from 'app/client/ui/forms';
import {createUserImage} from 'app/client/ui/UserImage';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
@ -29,13 +32,10 @@ import {
Observable,
styled
} from 'grainjs';
import {createPopper, Options as PopperOptions} from '@popperjs/core';
import * as ko from 'knockout';
import moment from 'moment';
import maxSize from 'popper-max-size-modifier';
import flatMap = require('lodash/flatMap');
import {autoGrow} from 'app/client/ui/forms';
import {autoFocus} from 'app/client/lib/domUtils';
const testId = makeTestId('test-discussion-');
const t = makeT('DiscussionEditor');
@ -416,7 +416,7 @@ class CommentView extends Disposable {
this.props.isReply ? testId('reply') : testId('comment'),
dom.on('click', () => {
if (this.props.isReply) { return; }
trigger(this._bodyDom, CommentView.SELECT, comment);
domDispatch(this._bodyDom, CommentView.SELECT, comment);
if (!this._resolved.get()) { return; }
this._expanded.set(!this._expanded.get());
}),
@ -474,11 +474,11 @@ class CommentView extends Disposable {
const value = text.get();
text.set("");
await topic.update(comment, value);
trigger(this._bodyDom, CommentView.CANCEL, this);
domDispatch(this._bodyDom, CommentView.CANCEL, this);
this.isEditing.set(false);
},
onCancel: () => {
trigger(this._bodyDom, CommentView.CANCEL, this);
domDispatch(this._bodyDom, CommentView.CANCEL, this);
this.isEditing.set(false);
},
mode: 'start',
@ -583,7 +583,7 @@ class CommentView extends Disposable {
}
private _edit() {
trigger(this._bodyDom, CommentView.EDIT, this);
domDispatch(this._bodyDom, CommentView.EDIT, this);
this.isEditing.set(true);
}
}
@ -1334,21 +1334,7 @@ const cssHoverButton = styled(cssCloseButton, `
// transform: rotate(180deg);
// `);
function domOnCustom(name: string, handler: (args: any, event: Event, element: Element) => void) {
return (el: Element) => {
dom.onElem(el, name, (ev, target) => {
const cv = ev as CustomEvent;
handler(cv.detail.args ?? {}, ev, target);
});
};
}
function trigger(element: Element, name: string, args?: any) {
element.dispatchEvent(new CustomEvent(name, {
bubbles: true,
detail: {args}
}));
}
const cssResolvedBlock = styled('div', `
margin-top: 5px;

View File

@ -149,7 +149,8 @@ export interface ILimit {
export interface IBillingOrgSettings {
name: string;
domain: string;
domain: string|null;
customLogoUrl?: string|null;
}
// Full description of billing account, including nested list of orgs and managers.
@ -204,7 +205,7 @@ export interface BillingAPI {
getSubscription(): Promise<IBillingSubscription>;
getBillingAccount(): Promise<FullBillingAccount>;
updateBillingManagers(delta: ManagerDelta): Promise<void>;
updateSettings(settings: IBillingOrgSettings): Promise<void>;
updateSettings(settings: Partial<IBillingOrgSettings>): Promise<void>;
subscriptionStatus(planId: string): Promise<boolean>;
createFreeTeam(name: string, domain: string): Promise<void>;
createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{
@ -261,7 +262,7 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
});
}
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
public async updateSettings(settings?: Partial<IBillingOrgSettings>): Promise<void> {
await this.request(`${this._url}/api/billing/settings`, {
method: 'POST',
body: JSON.stringify({ settings })

View File

@ -4,8 +4,8 @@ import {UserPrefs} from 'app/common/Prefs';
// User profile info for the user. When using Cognito, it is fetched during login.
export interface UserProfile {
email: string; // TODO: Used inconsistently: as lowercase login email or display email.
loginEmail?: string; // When set, this is consistently normalized (lowercase) login email.
name: string;
loginEmail?: string; // When set, this is consistently normalized (lowercase) login email.
picture?: string|null; // when present, a url to a public image of unspecified dimensions.
anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized).
connectId?: string|null, // used by GristConnect to identify user in external provider.

View File

@ -56,7 +56,10 @@ export interface UserOrgPrefs extends Prefs {
seenDocTours?: string[];
}
export type OrgPrefs = Prefs;
export interface OrgPrefs extends Prefs {
/* The URL (might be data url) of the custom logo to use for the org. */
customLogoUrl?: string|null;
}
/**
* List of all deprecated warnings that user can see and dismiss.

View File

@ -451,6 +451,9 @@ export const ThemeColors = t.iface([], {
"markdown-cell-light-bg": "string",
"markdown-cell-light-border": "string",
"markdown-cell-medium-border": "string",
"app-header-bg": "string",
"app-header-hover-bg": "string",
"app-header-border": "string",
});
const exportedTypeSuite: t.ITypeSuite = {

View File

@ -589,6 +589,11 @@ export interface ThemeColors {
'markdown-cell-light-bg': string;
'markdown-cell-light-border': string;
'markdown-cell-medium-border': string;
/* App Header */
'app-header-bg': string;
'app-header-hover-bg': string;
'app-header-border': string;
}
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;

View File

@ -568,4 +568,9 @@ export const GristDark: ThemeColors = {
'markdown-cell-light-bg': '#494958',
'markdown-cell-light-border': '#32323F',
'markdown-cell-medium-border': '#555563',
/* App Header */
'app-header-bg': '#32323F',
'app-header-border': '#FFFFFF00',
'app-header-hover-bg': 'rgba(111,111,125,0.6)',
};

View File

@ -568,4 +568,9 @@ export const GristLight: ThemeColors = {
'markdown-cell-light-bg': '#F7F7F7',
'markdown-cell-light-border': '#E8E8E8',
'markdown-cell-medium-border': '#D9D9D9',
/* App header */
'app-header-bg': 'var(--grist-theme-page-panels-main-panel-bg)',
'app-header-border': 'var(--grist-theme-menu-border)',
'app-header-hover-bg': 'var(--grist-theme-hover)',
};

View File

@ -25,6 +25,8 @@ describe('limits', function() {
testUtils.setTmpLogLevel('error');
this.timeout('10s');
before(async function() {
home = new TestServer(this);
await home.start(["home", "docs"]);
@ -222,7 +224,9 @@ describe('limits', function() {
});
it('can enforce limits on number of doc shares', async function() {
this.timeout(4000); // This can exceed the default of 2s on Jenkins
// This can exceed the default of 2s on Jenkins
// - Changed from 4s to 8s on 2024-10-04
this.timeout('8s');
await setFeatures({maxSharesPerDoc: 3, workspaces: true});
const wsId = await api.newWorkspace({name: 'shares'}, 'docs');