(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

@@ -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 */
`);