mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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.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') { | ||||
|     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 = {}; | ||||
|  | ||||
| @ -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}; | ||||
|  | ||||
| @ -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'; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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 */ | ||||
| `);
 | ||||
| 
 | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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 }) | ||||
|  | ||||
| @ -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.
 | ||||
|  | ||||
| @ -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. | ||||
|  | ||||
| @ -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 = { | ||||
|  | ||||
| @ -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>; | ||||
|  | ||||
| @ -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)', | ||||
| }; | ||||
|  | ||||
| @ -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)', | ||||
| }; | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user