mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
e8f9da9b5c
commit
0bdc838975
@ -64,3 +64,25 @@ export function stopEvent(ev: Event) {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopImmediatePropagation();
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
@ -48,13 +48,18 @@ export async function selectFiles(options: SelectFileOptions,
|
|||||||
if (typeof electronSelectFiles === 'function') {
|
if (typeof electronSelectFiles === 'function') {
|
||||||
result = await electronSelectFiles(getElectronOptions(options));
|
result = await electronSelectFiles(getElectronOptions(options));
|
||||||
} else {
|
} else {
|
||||||
const files: File[] = await openFilePicker(getFileDialogOptions(options));
|
result = await uploadFiles(await selectPicker(options), options, onProgress);
|
||||||
result = await uploadFiles(files, options, onProgress);
|
|
||||||
}
|
}
|
||||||
onProgress(100);
|
onProgress(100);
|
||||||
return result;
|
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.
|
// Helper to convert SelectFileOptions to the browser's FileDialogOptions.
|
||||||
function getFileDialogOptions(options: SelectFileOptions): FileDialogOptions {
|
function getFileDialogOptions(options: SelectFileOptions): FileDialogOptions {
|
||||||
const resOptions: FileDialogOptions = {};
|
const resOptions: FileDialogOptions = {};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {getTheme} from 'app/client/ui/CustomThemes';
|
import {getTheme} from 'app/client/ui/CustomThemes';
|
||||||
import {cssLeftPane} from 'app/client/ui/PagePanels';
|
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 * as version from 'app/common/version';
|
||||||
import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
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 {Computed, Disposable, dom, DomContents, styled} from 'grainjs';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {getGristConfig} from 'app/common/urlUtils';
|
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 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
|
// Maps a name of a Product (from app/gen-server/entity/Product.ts) to a tag (pill) to show next
|
||||||
// to the org name.
|
// to the org name.
|
||||||
@ -68,26 +71,63 @@ export class AppHeader extends Disposable {
|
|||||||
|
|
||||||
private _appLogoOrgLink = Computed.create(this, this._appLogoOrg, (_use, {link}) => link);
|
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();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
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 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(
|
return cssAppHeader(
|
||||||
cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false),
|
cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false),
|
||||||
|
cssAppHeaderBox(
|
||||||
dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo(
|
dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo(
|
||||||
// Show version when hovering over the application icon.
|
// Show version when hovering over the application icon.
|
||||||
// Include gitcommit when known. Cast version.gitcommit since, depending
|
// Include gitcommit when known. Cast version.gitcommit since, depending
|
||||||
// on how Grist is compiled, tsc may believe it to be a constant and
|
// on how Grist is compiled, tsc may believe it to be a constant and
|
||||||
// believe that testing it is unnecessary.
|
// believe that testing it is unnecessary.
|
||||||
{title: `Version ${version.version}` +
|
{title},
|
||||||
((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')},
|
|
||||||
this._setHomePageUrl(orgLink),
|
this._setHomePageUrl(orgLink),
|
||||||
testId('dm-logo')
|
content(),
|
||||||
|
testId('logo'),
|
||||||
)),
|
)),
|
||||||
this._buildOrgLinkOrMenu(),
|
this._buildOrgLinkOrMenu(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,15 +137,18 @@ export class AppHeader extends Disposable {
|
|||||||
if (deploymentType === 'saas' && !currentValidUser && isTemplatesSite) {
|
if (deploymentType === 'saas' && !currentValidUser && isTemplatesSite) {
|
||||||
// When signed out and on the templates site (in SaaS Grist), link to the templates page.
|
// When signed out and on the templates site (in SaaS Grist), link to the templates page.
|
||||||
return cssOrgLink(
|
return cssOrgLink(
|
||||||
cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')),
|
cssOrgName(dom.text(this._appLogoOrgName), testId('orgname')),
|
||||||
{href: commonUrls.templates},
|
{href: commonUrls.templates},
|
||||||
testId('dm-org'),
|
testId('org'),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return cssOrg(
|
return cssOrg(
|
||||||
cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')),
|
cssOrgName(dom.text(this._appLogoOrgName), testId('orgname')),
|
||||||
productPill(this._currentOrg),
|
productPill(this._currentOrg),
|
||||||
dom.maybe(this._appLogoOrgName, () => cssDropdownIcon('Dropdown')),
|
dom.maybe(this._appLogoOrgName, () => [
|
||||||
|
cssSpacer(),
|
||||||
|
cssDropdownIcon('Dropdown'),
|
||||||
|
]),
|
||||||
menu(() => [
|
menu(() => [
|
||||||
menuSubHeader(
|
menuSubHeader(
|
||||||
this._appModel.isPersonal
|
this._appModel.isPersonal
|
||||||
@ -128,7 +171,7 @@ export class AppHeader extends Disposable {
|
|||||||
|
|
||||||
maybeAddSiteSwitcherSection(this._appModel),
|
maybeAddSiteSwitcherSection(this._appModel),
|
||||||
], { placement: 'bottom-start' }),
|
], { 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),
|
return cssProductPill(cssProductPill.cls('-' + pillTag),
|
||||||
options.large ? cssProductPill.cls('-large') : null,
|
options.large ? cssProductPill.cls('-large') : null,
|
||||||
pillTag,
|
pillTag,
|
||||||
testId('appheader-product-pill'));
|
testId('product-pill'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const cssAppHeader = styled('div', `
|
const cssAppHeader = styled('div._cssAppHeader', `
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
align-items: center;
|
|
||||||
background-color: ${theme.leftPanelBg};
|
background-color: ${theme.leftPanelBg};
|
||||||
|
padding: 0px;
|
||||||
|
padding: 8px;
|
||||||
|
.${cssLeftPane.className}-open & {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
&-widelogo {
|
||||||
|
padding: 0px !important;
|
||||||
|
}
|
||||||
&, &:hover, &:focus {
|
&, &:hover, &:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
outline: 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;
|
flex: none;
|
||||||
height: 48px;
|
height: 100%;
|
||||||
width: 48px;
|
aspect-ratio: 1 / 1;
|
||||||
background-image: var(--icon-GristLogo);
|
text-decoration: none;
|
||||||
background-size: ${vars.logoSize};
|
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
background-color: inherit;
|
||||||
|
background-size: cover;
|
||||||
|
|
||||||
|
&-grist-logo {
|
||||||
|
background-image: var(--icon-GristLogo);
|
||||||
background-color: ${vars.logoBg};
|
background-color: ${vars.logoBg};
|
||||||
|
background-size: ${vars.logoSize};
|
||||||
|
}
|
||||||
|
|
||||||
.${cssAppHeader.className}-widelogo & {
|
.${cssAppHeader.className}-widelogo & {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
background-origin: content-box;
|
background-origin: content-box;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
border-right: none !important;
|
||||||
|
background-size: contain;
|
||||||
}
|
}
|
||||||
.${cssLeftPane.className}-open .${cssAppHeader.className}-widelogo & {
|
.${cssLeftPane.className}-open .${cssAppHeader.className}-widelogo & {
|
||||||
background-image: var(--icon-GristWideLogo, var(--icon-GristLogo));
|
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;
|
margin-right: 8px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssOrg = styled('div', `
|
const cssSpacer = styled('div', `
|
||||||
|
display: none;
|
||||||
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssOrg = styled('div._cssOrg', `
|
||||||
display: none;
|
display: none;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
flex-basis: 0px;
|
||||||
|
overflow: hidden;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: calc(100% - 48px);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: ${theme.hover};
|
|
||||||
}
|
|
||||||
|
|
||||||
.${cssLeftPane.className}-open & {
|
.${cssLeftPane.className}-open & {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
border-left: 1px solid ${theme.appHeaderBorder};
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssOrgLink = styled('a', `
|
const cssOrgLink = styled('a.cssOrgLink', `
|
||||||
display: none;
|
display: none;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -308,6 +391,10 @@ const cssOrgLink = styled('a', `
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.${cssLeftPane.className}-open & {
|
||||||
|
border-left: 1px solid ${theme.appHeaderBorder};
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: ${theme.text};
|
color: ${theme.text};
|
||||||
background-color: ${theme.hover};
|
background-color: ${theme.hover};
|
||||||
|
@ -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';
|
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 type ProductFlavor = 'grist' | 'fieldlink';
|
||||||
|
|
||||||
export interface CustomTheme {
|
export interface CustomTheme {
|
||||||
@ -14,13 +26,15 @@ export function getFlavor(org?: string): ProductFlavor {
|
|||||||
const themeOrg = new URLSearchParams(window.location.search).get('__themeOrg');
|
const themeOrg = new URLSearchParams(window.location.search).get('__themeOrg');
|
||||||
if (themeOrg) { org = themeOrg; }
|
if (themeOrg) { org = themeOrg; }
|
||||||
|
|
||||||
if (!org) {
|
// If still not set, use the org from the config.
|
||||||
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
org ||= getGristConfig()?.org;
|
||||||
org = gristConfig && gristConfig.org;
|
|
||||||
}
|
// If the org is 'fieldlink', use the fieldlink flavor.
|
||||||
if (org === 'fieldlink') {
|
if (org === 'fieldlink') {
|
||||||
return 'fieldlink';
|
return 'fieldlink';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For any other situation, use the grist flavor.
|
||||||
return 'grist';
|
return 'grist';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,35 +5,60 @@ import {icon} from 'app/client/ui2018/icons';
|
|||||||
|
|
||||||
export type Size = 'small' | 'medium' | 'large';
|
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
|
* 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.
|
* picture is missing. Also varies the color of the circle when using initials.
|
||||||
*/
|
*/
|
||||||
export function createUserImage(
|
export function createUserImage(
|
||||||
user: UserProfile|'exampleUser'|null, size: Size, ...args: DomElementArg[]
|
user: Partial<UserProfile>|'exampleUser'|null, size: Size, ...args: DomElementArg[]
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
let initials: string;
|
|
||||||
return cssUserImage(
|
return cssUserImage(
|
||||||
cssUserImage.cls('-' + size),
|
cssUserImage.cls('-' + size),
|
||||||
(user === 'exampleUser') ? [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')] :
|
...(function*() {
|
||||||
(!user || user.anonymous) ? cssUserImage.cls('-anon') :
|
if (user === 'exampleUser') {
|
||||||
[
|
yield [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')];
|
||||||
(user.picture ? cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))) : null),
|
} else if (!user || user.anonymous) {
|
||||||
dom.style('background-color', pickColor(user)),
|
yield cssUserImage.cls('-anon');
|
||||||
(initials = getInitials(user)).length > 1 ? cssUserImage.cls('-reduced') : null,
|
} else {
|
||||||
initials!,
|
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,
|
...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
|
* Extracts initials from a user, e.g. a FullUser. E.g. "Foo Bar" is turned into "FB", and
|
||||||
* "foo@example.com" into just "f".
|
* "foo@example.com" into just "f".
|
||||||
*
|
*
|
||||||
* Exported for testing.
|
* 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()) || '';
|
const source = (user.name && user.name.trim()) || (user.email && user.email.trim()) || '';
|
||||||
return source.split(/\s+/, 2).map(p => p.slice(0, 1)).join('');
|
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.
|
* 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;
|
let c = hashCode(user.name + ':' + user.email) % someColors.length;
|
||||||
if (c < 0) { c += someColors.length; }
|
if (c < 0) { c += someColors.length; }
|
||||||
return someColors[c];
|
return someColors[c];
|
||||||
@ -87,22 +112,26 @@ export const cssUserImage = styled('div', `
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
&-small {
|
||||||
width: 24px;
|
--icon-size: 24px;
|
||||||
height: 24px;
|
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
--reduced-font-size: 12px;
|
--reduced-font-size: 12px;
|
||||||
}
|
}
|
||||||
&-medium {
|
&-medium {
|
||||||
width: 32px;
|
--icon-size: 32px;
|
||||||
height: 32px;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
--reduced-font-size: 16px;
|
--reduced-font-size: 16px;
|
||||||
}
|
}
|
||||||
|
&-border {
|
||||||
|
--border-size: 2px;
|
||||||
|
}
|
||||||
&-large {
|
&-large {
|
||||||
width: 40px;
|
--icon-size: 40px;
|
||||||
height: 40px;
|
|
||||||
font-size: 22.5px;
|
font-size: 22.5px;
|
||||||
--reduced-font-size: 20px;
|
--reduced-font-size: 20px;
|
||||||
}
|
}
|
||||||
@ -117,7 +146,9 @@ export const cssUserImage = styled('div', `
|
|||||||
&-reduced {
|
&-reduced {
|
||||||
font-size: var(--reduced-font-size);
|
font-size: var(--reduced-font-size);
|
||||||
}
|
}
|
||||||
|
&-square {
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
&-example {
|
&-example {
|
||||||
background-color: ${colors.slate};
|
background-color: ${colors.slate};
|
||||||
border: 1px solid ${colors.slate};
|
border: 1px solid ${colors.slate};
|
||||||
@ -130,7 +161,7 @@ const cssUserPicture = styled('img', `
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background-color: ${theme.menuBg};
|
background-color: ${theme.menuBg};
|
||||||
border-radius: 100px;
|
border-radius: inherit;
|
||||||
box-sizing: content-box; /* keep the border outside of the size of the image */
|
box-sizing: content-box; /* keep the border outside of the size of the image */
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
@ -903,6 +903,11 @@ export const theme = {
|
|||||||
colors.mediumGreyOpaque),
|
colors.mediumGreyOpaque),
|
||||||
markdownCellMediumBorder: new CustomProp('theme-markdown-cell-medium-border', undefined,
|
markdownCellMediumBorder: new CustomProp('theme-markdown-cell-medium-border', undefined,
|
||||||
colors.darkGrey),
|
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');
|
const cssColors = values(colors).map(v => v.decl()).join('\n');
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
|
import {createPopper, Options as PopperOptions} from '@popperjs/core';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
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 {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||||
import {createObsArray} from 'app/client/lib/koArrayWrap';
|
import {createObsArray} from 'app/client/lib/koArrayWrap';
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||||
import {CellRec, ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {CellRec, ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import {reportError} from 'app/client/models/errors';
|
import {reportError} from 'app/client/models/errors';
|
||||||
import {RowSource, RowWatcher} from 'app/client/models/rowset';
|
import {RowSource, RowWatcher} from 'app/client/models/rowset';
|
||||||
|
import {autoGrow} from 'app/client/ui/forms';
|
||||||
import {createUserImage} from 'app/client/ui/UserImage';
|
import {createUserImage} from 'app/client/ui/UserImage';
|
||||||
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
||||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||||
@ -29,13 +32,10 @@ import {
|
|||||||
Observable,
|
Observable,
|
||||||
styled
|
styled
|
||||||
} from 'grainjs';
|
} from 'grainjs';
|
||||||
import {createPopper, Options as PopperOptions} from '@popperjs/core';
|
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import maxSize from 'popper-max-size-modifier';
|
import maxSize from 'popper-max-size-modifier';
|
||||||
import flatMap = require('lodash/flatMap');
|
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 testId = makeTestId('test-discussion-');
|
||||||
const t = makeT('DiscussionEditor');
|
const t = makeT('DiscussionEditor');
|
||||||
@ -416,7 +416,7 @@ class CommentView extends Disposable {
|
|||||||
this.props.isReply ? testId('reply') : testId('comment'),
|
this.props.isReply ? testId('reply') : testId('comment'),
|
||||||
dom.on('click', () => {
|
dom.on('click', () => {
|
||||||
if (this.props.isReply) { return; }
|
if (this.props.isReply) { return; }
|
||||||
trigger(this._bodyDom, CommentView.SELECT, comment);
|
domDispatch(this._bodyDom, CommentView.SELECT, comment);
|
||||||
if (!this._resolved.get()) { return; }
|
if (!this._resolved.get()) { return; }
|
||||||
this._expanded.set(!this._expanded.get());
|
this._expanded.set(!this._expanded.get());
|
||||||
}),
|
}),
|
||||||
@ -474,11 +474,11 @@ class CommentView extends Disposable {
|
|||||||
const value = text.get();
|
const value = text.get();
|
||||||
text.set("");
|
text.set("");
|
||||||
await topic.update(comment, value);
|
await topic.update(comment, value);
|
||||||
trigger(this._bodyDom, CommentView.CANCEL, this);
|
domDispatch(this._bodyDom, CommentView.CANCEL, this);
|
||||||
this.isEditing.set(false);
|
this.isEditing.set(false);
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
trigger(this._bodyDom, CommentView.CANCEL, this);
|
domDispatch(this._bodyDom, CommentView.CANCEL, this);
|
||||||
this.isEditing.set(false);
|
this.isEditing.set(false);
|
||||||
},
|
},
|
||||||
mode: 'start',
|
mode: 'start',
|
||||||
@ -583,7 +583,7 @@ class CommentView extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _edit() {
|
private _edit() {
|
||||||
trigger(this._bodyDom, CommentView.EDIT, this);
|
domDispatch(this._bodyDom, CommentView.EDIT, this);
|
||||||
this.isEditing.set(true);
|
this.isEditing.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1334,21 +1334,7 @@ const cssHoverButton = styled(cssCloseButton, `
|
|||||||
// transform: rotate(180deg);
|
// 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', `
|
const cssResolvedBlock = styled('div', `
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
|
@ -149,7 +149,8 @@ export interface ILimit {
|
|||||||
|
|
||||||
export interface IBillingOrgSettings {
|
export interface IBillingOrgSettings {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string|null;
|
||||||
|
customLogoUrl?: string|null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full description of billing account, including nested list of orgs and managers.
|
// Full description of billing account, including nested list of orgs and managers.
|
||||||
@ -204,7 +205,7 @@ export interface BillingAPI {
|
|||||||
getSubscription(): Promise<IBillingSubscription>;
|
getSubscription(): Promise<IBillingSubscription>;
|
||||||
getBillingAccount(): Promise<FullBillingAccount>;
|
getBillingAccount(): Promise<FullBillingAccount>;
|
||||||
updateBillingManagers(delta: ManagerDelta): Promise<void>;
|
updateBillingManagers(delta: ManagerDelta): Promise<void>;
|
||||||
updateSettings(settings: IBillingOrgSettings): Promise<void>;
|
updateSettings(settings: Partial<IBillingOrgSettings>): Promise<void>;
|
||||||
subscriptionStatus(planId: string): Promise<boolean>;
|
subscriptionStatus(planId: string): Promise<boolean>;
|
||||||
createFreeTeam(name: string, domain: string): Promise<void>;
|
createFreeTeam(name: string, domain: string): Promise<void>;
|
||||||
createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{
|
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`, {
|
await this.request(`${this._url}/api/billing/settings`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ settings })
|
body: JSON.stringify({ settings })
|
||||||
|
@ -4,8 +4,8 @@ import {UserPrefs} from 'app/common/Prefs';
|
|||||||
// User profile info for the user. When using Cognito, it is fetched during login.
|
// User profile info for the user. When using Cognito, it is fetched during login.
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
email: string; // TODO: Used inconsistently: as lowercase login email or display email.
|
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;
|
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.
|
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).
|
anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized).
|
||||||
connectId?: string|null, // used by GristConnect to identify user in external provider.
|
connectId?: string|null, // used by GristConnect to identify user in external provider.
|
||||||
|
@ -56,7 +56,10 @@ export interface UserOrgPrefs extends Prefs {
|
|||||||
seenDocTours?: string[];
|
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.
|
* List of all deprecated warnings that user can see and dismiss.
|
||||||
|
@ -451,6 +451,9 @@ export const ThemeColors = t.iface([], {
|
|||||||
"markdown-cell-light-bg": "string",
|
"markdown-cell-light-bg": "string",
|
||||||
"markdown-cell-light-border": "string",
|
"markdown-cell-light-border": "string",
|
||||||
"markdown-cell-medium-border": "string",
|
"markdown-cell-medium-border": "string",
|
||||||
|
"app-header-bg": "string",
|
||||||
|
"app-header-hover-bg": "string",
|
||||||
|
"app-header-border": "string",
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportedTypeSuite: t.ITypeSuite = {
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
|
@ -589,6 +589,11 @@ export interface ThemeColors {
|
|||||||
'markdown-cell-light-bg': string;
|
'markdown-cell-light-bg': string;
|
||||||
'markdown-cell-light-border': string;
|
'markdown-cell-light-border': string;
|
||||||
'markdown-cell-medium-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>;
|
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;
|
||||||
|
@ -568,4 +568,9 @@ export const GristDark: ThemeColors = {
|
|||||||
'markdown-cell-light-bg': '#494958',
|
'markdown-cell-light-bg': '#494958',
|
||||||
'markdown-cell-light-border': '#32323F',
|
'markdown-cell-light-border': '#32323F',
|
||||||
'markdown-cell-medium-border': '#555563',
|
'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)',
|
||||||
};
|
};
|
||||||
|
@ -568,4 +568,9 @@ export const GristLight: ThemeColors = {
|
|||||||
'markdown-cell-light-bg': '#F7F7F7',
|
'markdown-cell-light-bg': '#F7F7F7',
|
||||||
'markdown-cell-light-border': '#E8E8E8',
|
'markdown-cell-light-border': '#E8E8E8',
|
||||||
'markdown-cell-medium-border': '#D9D9D9',
|
'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)',
|
||||||
};
|
};
|
||||||
|
@ -25,6 +25,8 @@ describe('limits', function() {
|
|||||||
|
|
||||||
testUtils.setTmpLogLevel('error');
|
testUtils.setTmpLogLevel('error');
|
||||||
|
|
||||||
|
this.timeout('10s');
|
||||||
|
|
||||||
before(async function() {
|
before(async function() {
|
||||||
home = new TestServer(this);
|
home = new TestServer(this);
|
||||||
await home.start(["home", "docs"]);
|
await home.start(["home", "docs"]);
|
||||||
@ -222,7 +224,9 @@ describe('limits', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can enforce limits on number of doc shares', async 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});
|
await setFeatures({maxSharesPerDoc: 3, workspaces: true});
|
||||||
const wsId = await api.newWorkspace({name: 'shares'}, 'docs');
|
const wsId = await api.newWorkspace({name: 'shares'}, 'docs');
|
||||||
|
Loading…
Reference in New Issue
Block a user