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.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