mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 07:51:56 +03:00
Settings improvements (#10924)
- Address https://github.com/enso-org/cloud-v2/issues/1457 - Show x instead of a circle for close tab icon on Windows and Linux - Free / Solo no longer shows Organization, Members, User Groups, and Activity Log - Invite button should only be there for the admin in Team or above (should already be present) - Should list the required symbols in the password change. - Changing headshot appeared briefly and then disappeared. - Fix https://github.com/enso-org/cloud-v2/issues/1455 - The `Alert` component no longer focuses itself on every keystroke. - Fix #10988 - Same issue as above. Other related changes: - Fix word wrapping in tooltips when a word is wider than the entire tooltip - Add word wrapping to "members" table in members tab - Limit length of "email" column of members table in "user groups" tab - Hide empty sections - Add text to empty "user groups" table in "user groups" tab - ~~Close "set organization name" modal when organization name is set~~ - Already fixed by another PR # Important Notes None
This commit is contained in:
parent
c87053d600
commit
3bcc694af9
45
app/.vscode/launch.json
vendored
45
app/.vscode/launch.json
vendored
@ -6,49 +6,80 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Dashboard",
|
"name": "Dashboard",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"]
|
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"],
|
||||||
|
"outputCapture": "std"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Dashboard (Electron, Linux)",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["run", "--filter", "enso", "watch:linux"],
|
||||||
|
"outputCapture": "std"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Dashboard (Electron, macOS)",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["run", "--filter", "enso", "watch:macos"],
|
||||||
|
"outputCapture": "std"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Dashboard (Electron, Windows)",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["run", "--filter", "enso", "watch:windows"],
|
||||||
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "GUI",
|
"name": "GUI",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["dev:gui"]
|
"runtimeArgs": ["dev:gui"],
|
||||||
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "GUI (Storybook)",
|
"name": "GUI (Storybook)",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"]
|
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"],
|
||||||
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Dashboard (Build)",
|
"name": "Dashboard (Build)",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["build:gui"]
|
"runtimeArgs": ["build:gui"],
|
||||||
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Dashboard (E2E UI)",
|
"name": "Dashboard (E2E UI)",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"]
|
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"],
|
||||||
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "GUI (E2E UI)",
|
"name": "GUI (E2E UI)",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"]
|
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"],
|
||||||
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Dashboard (All tests)",
|
"name": "Dashboard (All tests)",
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "pnpm",
|
||||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"]
|
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"],
|
||||||
|
"outputCapture": "std"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
/** @file Test the organization settings tab. */
|
/** @file Test the organization settings tab. */
|
||||||
import * as test from '@playwright/test'
|
import * as test from '@playwright/test'
|
||||||
|
|
||||||
|
import { Plan } from 'enso-common/src/services/Backend'
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
|
|
||||||
test.test('organization settings', async ({ page }) => {
|
test.test('organization settings', async ({ page }) => {
|
||||||
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
|
const api = await actions.mockAllAndLoginAndExposeAPI({
|
||||||
|
page,
|
||||||
|
setupAPI: (theApi) => {
|
||||||
|
theApi.setPlan(Plan.team)
|
||||||
|
},
|
||||||
|
})
|
||||||
const localActions = actions.settings.organization
|
const localActions = actions.settings.organization
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
@ -76,7 +82,12 @@ test.test('organization settings', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test.test('upload organization profile picture', async ({ page }) => {
|
test.test('upload organization profile picture', async ({ page }) => {
|
||||||
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
|
const api = await actions.mockAllAndLoginAndExposeAPI({
|
||||||
|
page,
|
||||||
|
setupAPI: (theApi) => {
|
||||||
|
theApi.setPlan(Plan.team)
|
||||||
|
},
|
||||||
|
})
|
||||||
const localActions = actions.settings.organizationProfilePicture
|
const localActions = actions.settings.organizationProfilePicture
|
||||||
|
|
||||||
await localActions.go(page)
|
await localActions.go(page)
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
/** @file Alert component. */
|
/** @file Alert component. */
|
||||||
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
|
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
|
||||||
|
|
||||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
||||||
|
|
||||||
@ -57,6 +56,7 @@ export interface AlertProps
|
|||||||
HTMLAttributes<HTMLDivElement> {}
|
HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
/** Alert component. */
|
/** Alert component. */
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
export const Alert = forwardRef(function Alert(
|
export const Alert = forwardRef(function Alert(
|
||||||
props: AlertProps,
|
props: AlertProps,
|
||||||
ref: ForwardedRef<HTMLDivElement>,
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
@ -71,11 +71,7 @@ export const Alert = forwardRef(function Alert(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
|
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
|
||||||
ref={mergeRefs(ref, (e) => {
|
ref={ref}
|
||||||
if (variant === 'error') {
|
|
||||||
e?.focus()
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
{...containerProps}
|
{...containerProps}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,49 +1,44 @@
|
|||||||
/** @file A button for closing a modal. */
|
/** @file A button for closing a modal. */
|
||||||
|
import DismissIcon from '#/assets/dismiss.svg'
|
||||||
import * as React from 'react'
|
import { Button, type ButtonProps } from '#/components/AriaComponents/Button'
|
||||||
|
import { useText } from '#/providers/TextProvider'
|
||||||
import Dismiss from '#/assets/dismiss.svg'
|
import { twMerge } from '#/utilities/tailwindMerge'
|
||||||
|
import { isOnMacOS } from 'enso-common/src/detect'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
|
||||||
|
|
||||||
import * as button from '#/components/AriaComponents/Button'
|
|
||||||
|
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
|
||||||
|
|
||||||
// ===================
|
// ===================
|
||||||
// === CloseButton ===
|
// === CloseButton ===
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
/** Props for a {@link CloseButton}. */
|
/** Props for a {@link CloseButton}. */
|
||||||
export type CloseButtonProps = Omit<
|
export type CloseButtonProps = Omit<ButtonProps, 'children' | 'rounding' | 'size' | 'variant'>
|
||||||
button.ButtonProps,
|
|
||||||
'children' | 'rounding' | 'size' | 'variant'
|
|
||||||
>
|
|
||||||
|
|
||||||
/** A styled button with a close icon that appears on hover. */
|
/** A styled button with a close icon that appears on hover. */
|
||||||
export function CloseButton(props: CloseButtonProps) {
|
export function CloseButton(props: CloseButtonProps) {
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = useText()
|
||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
icon = Dismiss,
|
icon = DismissIcon,
|
||||||
tooltip = false,
|
tooltip = false,
|
||||||
'aria-label': ariaLabel = getText('closeModalShortcut'),
|
'aria-label': ariaLabel = getText('closeModalShortcut'),
|
||||||
...buttonProps
|
...buttonProps
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button.Button
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
className={(values) =>
|
className={(values) =>
|
||||||
tailwindMerge.twMerge(
|
twMerge(
|
||||||
'bg-primary/30 hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
|
'hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
|
||||||
|
isOnMacOS() ? 'bg-primary/30' : (
|
||||||
|
'text-primary/90 hover:text-primary focus-visible:text-primary'
|
||||||
|
),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
// @ts-expect-error ts fails to infer the type of the className prop
|
// @ts-expect-error ts fails to infer the type of the className prop
|
||||||
typeof className === 'function' ? className(values) : className,
|
typeof className === 'function' ? className(values) : className,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
tooltip={tooltip}
|
tooltip={tooltip}
|
||||||
showIconOnHover
|
showIconOnHover={isOnMacOS()}
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
rounded="full"
|
rounded="full"
|
||||||
extraClickZone="medium"
|
extraClickZone="medium"
|
||||||
|
@ -12,7 +12,7 @@ import * as text from '../Text'
|
|||||||
// =================
|
// =================
|
||||||
|
|
||||||
export const TOOLTIP_STYLES = twv.tv({
|
export const TOOLTIP_STYLES = twv.tv({
|
||||||
base: 'group flex justify-center items-center text-center text-balance break-all z-50',
|
base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere] z-tooltip',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
custom: '',
|
custom: '',
|
||||||
|
@ -9,9 +9,9 @@ import * as reactQuery from '@tanstack/react-query'
|
|||||||
|
|
||||||
import { IS_DEV_MODE } from 'enso-common/src/detect'
|
import { IS_DEV_MODE } from 'enso-common/src/detect'
|
||||||
|
|
||||||
import cross from '#/assets/cross.svg'
|
import CrossIcon from '#/assets/cross.svg'
|
||||||
import DevtoolsLogo from '#/assets/enso_logo.svg'
|
import DevtoolsLogo from '#/assets/enso_logo.svg'
|
||||||
import trash from '#/assets/trash.svg'
|
import TrashIcon from '#/assets/trash.svg'
|
||||||
|
|
||||||
import { SETUP_PATH } from '#/appUtils'
|
import { SETUP_PATH } from '#/appUtils'
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ import {
|
|||||||
} from '#/providers/FeatureFlagsProvider'
|
} from '#/providers/FeatureFlagsProvider'
|
||||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||||
import * as backend from '#/services/Backend'
|
import * as backend from '#/services/Backend'
|
||||||
import LocalStorage from '#/utilities/LocalStorage'
|
import LocalStorage, { type LocalStorageData } from '#/utilities/LocalStorage'
|
||||||
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
|
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,6 +60,10 @@ export function EnsoDevtools() {
|
|||||||
const enableVersionChecker = useEnableVersionChecker()
|
const enableVersionChecker = useEnableVersionChecker()
|
||||||
const setEnableVersionChecker = useSetEnableVersionChecker()
|
const setEnableVersionChecker = useSetEnableVersionChecker()
|
||||||
const { localStorage } = useLocalStorage()
|
const { localStorage } = useLocalStorage()
|
||||||
|
const [localStorageState, setLocalStorageState] = React.useState<Partial<LocalStorageData>>({})
|
||||||
|
|
||||||
|
// Re-render when localStorage changes.
|
||||||
|
React.useEffect(() => localStorage.subscribeAll(setLocalStorageState), [localStorage])
|
||||||
|
|
||||||
const featureFlags = useFeatureFlags()
|
const featureFlags = useFeatureFlags()
|
||||||
const setFeatureFlags = useSetFeatureFlags()
|
const setFeatureFlags = useSetFeatureFlags()
|
||||||
@ -157,46 +161,6 @@ export function EnsoDevtools() {
|
|||||||
)}
|
)}
|
||||||
</ariaComponents.Form>
|
</ariaComponents.Form>
|
||||||
|
|
||||||
<Separator orientation="horizontal" className="my-3" />
|
|
||||||
|
|
||||||
<div className="mb-2 flex w-full items-center justify-between">
|
|
||||||
<Text variant="subtitle">{getText('localStorage')}</Text>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
aria-label={getText('deleteAll')}
|
|
||||||
size="small"
|
|
||||||
variant="icon"
|
|
||||||
icon={trash}
|
|
||||||
onPress={() => {
|
|
||||||
for (const [key] of unsafeEntries(LocalStorage.keyMetadata)) {
|
|
||||||
localStorage.delete(key)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
|
|
||||||
<div key={key} className="flex w-full items-center justify-between gap-1">
|
|
||||||
<Text variant="body">
|
|
||||||
{key
|
|
||||||
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
|
|
||||||
.replace(/^./, (m) => m.toUpperCase())}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="icon"
|
|
||||||
size="small"
|
|
||||||
aria-label={getText('delete')}
|
|
||||||
icon={cross}
|
|
||||||
onPress={() => {
|
|
||||||
localStorage.delete(key)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||||
|
|
||||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||||
@ -292,6 +256,47 @@ export function EnsoDevtools() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ariaComponents.Form>
|
</ariaComponents.Form>
|
||||||
|
|
||||||
|
<Separator orientation="horizontal" className="my-3" />
|
||||||
|
|
||||||
|
<div className="mb-2 flex w-full items-center justify-between">
|
||||||
|
<Text variant="subtitle">{getText('localStorage')}</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
aria-label={getText('deleteAll')}
|
||||||
|
size="small"
|
||||||
|
variant="icon"
|
||||||
|
icon={TrashIcon}
|
||||||
|
onPress={() => {
|
||||||
|
for (const [key] of unsafeEntries(LocalStorage.keyMetadata)) {
|
||||||
|
localStorage.delete(key)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
|
||||||
|
<div key={key} className="flex w-full items-center justify-between gap-1">
|
||||||
|
<Text variant="body">
|
||||||
|
{key
|
||||||
|
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
|
||||||
|
.replace(/^./, (m) => m.toUpperCase())}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
size="small"
|
||||||
|
isDisabled={localStorageState[key] == null}
|
||||||
|
aria-label={getText('delete')}
|
||||||
|
icon={CrossIcon}
|
||||||
|
onPress={() => {
|
||||||
|
localStorage.delete(key)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</ariaComponents.DialogTrigger>
|
</ariaComponents.DialogTrigger>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
@ -3,7 +3,7 @@ import { Button, type ButtonProps } from '#/components/AriaComponents'
|
|||||||
import { tv } from '#/utilities/tailwindVariants'
|
import { tv } from '#/utilities/tailwindVariants'
|
||||||
|
|
||||||
const SIDEBAR_TAB_BUTTON_STYLES = tv({
|
const SIDEBAR_TAB_BUTTON_STYLES = tv({
|
||||||
base: 'font-medium',
|
base: 'z-1 font-medium',
|
||||||
variants: {
|
variants: {
|
||||||
isActive: { true: 'bg-white opacity-100' },
|
isActive: { true: 'bg-white opacity-100' },
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
/** @file Settings tab for viewing and editing organization members. */
|
/** @file Settings tab for viewing and editing organization members. */
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
import { useMutation, useSuspenseQueries } from '@tanstack/react-query'
|
import { useMutation, useSuspenseQueries } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||||
@ -92,10 +90,10 @@ export default function MembersSettingsSection() {
|
|||||||
<table className="table-fixed self-start rounded-rows">
|
<table className="table-fixed self-start rounded-rows">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="h-row">
|
<tr className="h-row">
|
||||||
<th className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||||
{getText('name')}
|
{getText('name')}
|
||||||
</th>
|
</th>
|
||||||
<th className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||||
{getText('status')}
|
{getText('status')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -103,9 +101,13 @@ export default function MembersSettingsSection() {
|
|||||||
<tbody className="select-text">
|
<tbody className="select-text">
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<tr key={member.email} className="group h-row rounded-rows-child">
|
<tr key={member.email} className="group h-row rounded-rows-child">
|
||||||
<td className="border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
|
<td className="max-w-48 border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
|
||||||
<span className="block text-sm">{member.email}</span>
|
<ariaComponents.Text truncate="1" className="block">
|
||||||
<span className="block text-xs text-primary/50">{member.name}</span>
|
{member.email}
|
||||||
|
</ariaComponents.Text>
|
||||||
|
<ariaComponents.Text truncate="1" className="block text-2xs text-primary/40">
|
||||||
|
{member.name}
|
||||||
|
</ariaComponents.Text>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
|
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
@ -132,11 +132,11 @@ export default function MembersTable(props: MembersTableProps) {
|
|||||||
<aria.TableHeader className="sticky top h-row">
|
<aria.TableHeader className="sticky top h-row">
|
||||||
<aria.Column
|
<aria.Column
|
||||||
isRowHeader
|
isRowHeader
|
||||||
className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
||||||
>
|
>
|
||||||
{getText('name')}
|
{getText('name')}
|
||||||
</aria.Column>
|
</aria.Column>
|
||||||
<aria.Column className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
<aria.Column className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||||
{getText('email')}
|
{getText('email')}
|
||||||
</aria.Column>
|
</aria.Column>
|
||||||
{/* Delete button. */}
|
{/* Delete button. */}
|
||||||
|
@ -1,13 +1,9 @@
|
|||||||
/** @file Rendering for a settings section. */
|
/** @file Rendering for a settings section. */
|
||||||
import * as React from 'react'
|
import { Text } from '#/components/AriaComponents'
|
||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
|
||||||
|
|
||||||
import type * as settingsData from '#/layouts/Settings/settingsData'
|
|
||||||
import SettingsEntry from '#/layouts/Settings/SettingsEntry'
|
|
||||||
|
|
||||||
import * as aria from '#/components/aria'
|
|
||||||
import FocusArea from '#/components/styled/FocusArea'
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
import type { SettingsContext, SettingsSectionData } from '#/layouts/Settings/settingsData'
|
||||||
|
import SettingsEntry from '#/layouts/Settings/SettingsEntry'
|
||||||
|
import { useText } from '#/providers/TextProvider'
|
||||||
|
|
||||||
// =======================
|
// =======================
|
||||||
// === SettingsSection ===
|
// === SettingsSection ===
|
||||||
@ -15,24 +11,25 @@ import FocusArea from '#/components/styled/FocusArea'
|
|||||||
|
|
||||||
/** Props for a {@link SettingsSection}. */
|
/** Props for a {@link SettingsSection}. */
|
||||||
export interface SettingsSectionProps {
|
export interface SettingsSectionProps {
|
||||||
readonly context: settingsData.SettingsContext
|
readonly context: SettingsContext
|
||||||
readonly data: settingsData.SettingsSectionData
|
readonly data: SettingsSectionData
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rendering for a settings section. */
|
/** Rendering for a settings section. */
|
||||||
export default function SettingsSection(props: SettingsSectionProps) {
|
export default function SettingsSection(props: SettingsSectionProps) {
|
||||||
const { context, data } = props
|
const { context, data } = props
|
||||||
const { nameId, focusArea = true, heading = true, entries } = data
|
const { nameId, focusArea = true, heading = true, entries } = data
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = useText()
|
||||||
|
const isVisible = entries.some((entry) => entry.getVisible?.(context) ?? true)
|
||||||
|
|
||||||
return (
|
return !isVisible ? null : (
|
||||||
<FocusArea active={focusArea} direction="vertical">
|
<FocusArea active={focusArea} direction="vertical">
|
||||||
{(innerProps) => (
|
{(innerProps) => (
|
||||||
<div className="flex w-full flex-col gap-settings-section-header" {...innerProps}>
|
<div className="flex w-full flex-col gap-settings-section-header" {...innerProps}>
|
||||||
{!heading ? null : (
|
{!heading ? null : (
|
||||||
<aria.Heading level={2} className="h-[2.375rem] py-0.5 text-xl font-bold">
|
<Text.Heading level={2} weight="bold">
|
||||||
{getText(nameId)}
|
{getText(nameId)}
|
||||||
</aria.Heading>
|
</Text.Heading>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{entries.map((entry, i) => (
|
{entries.map((entry, i) => (
|
||||||
|
@ -223,6 +223,14 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
|||||||
</div>
|
</div>
|
||||||
</aria.Cell>
|
</aria.Cell>
|
||||||
</aria.Row>
|
</aria.Row>
|
||||||
|
: userGroups.length === 0 ?
|
||||||
|
<aria.Row className="h-row">
|
||||||
|
<aria.Cell className="col-span-2 px-2.5 placeholder">
|
||||||
|
{isAdmin ?
|
||||||
|
getText('youHaveNoUserGroupsAdmin')
|
||||||
|
: getText('youHaveNoUserGroupsNonAdmin')}
|
||||||
|
</aria.Cell>
|
||||||
|
</aria.Row>
|
||||||
: (userGroup) => (
|
: (userGroup) => (
|
||||||
<>
|
<>
|
||||||
<UserGroupRow userGroup={userGroup} doDeleteUserGroup={doDeleteUserGroup} />
|
<UserGroupRow userGroup={userGroup} doDeleteUserGroup={doDeleteUserGroup} />
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
/** @file A row representing a user in a table of users. */
|
/** @file A row representing a user in a table of users. */
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
import Cross2 from '#/assets/cross2.svg'
|
import Cross2 from '#/assets/cross2.svg'
|
||||||
|
|
||||||
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||||
@ -90,7 +88,9 @@ export default function UserRow(props: UserRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
</aria.Cell>
|
</aria.Cell>
|
||||||
<aria.Cell className="text whitespace-nowrap rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0 group-selected:bg-selected-frame">
|
<aria.Cell className="text whitespace-nowrap rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0 group-selected:bg-selected-frame">
|
||||||
|
<ariaComponents.Text nowrap truncate="1" className="block">
|
||||||
{user.email}
|
{user.email}
|
||||||
|
</ariaComponents.Text>
|
||||||
</aria.Cell>
|
</aria.Cell>
|
||||||
{doDeleteUserRaw == null ?
|
{doDeleteUserRaw == null ?
|
||||||
null
|
null
|
||||||
|
@ -152,6 +152,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
settingsTab: SettingsTabType.organization,
|
settingsTab: SettingsTabType.organization,
|
||||||
icon: PeopleSettingsIcon,
|
icon: PeopleSettingsIcon,
|
||||||
organizationOnly: true,
|
organizationOnly: true,
|
||||||
|
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
nameId: 'organizationSettingsSection',
|
nameId: 'organizationSettingsSection',
|
||||||
@ -285,7 +286,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
settingsTab: SettingsTabType.billingAndPlans,
|
settingsTab: SettingsTabType.billingAndPlans,
|
||||||
icon: CreditCardIcon,
|
icon: CreditCardIcon,
|
||||||
organizationOnly: true,
|
organizationOnly: true,
|
||||||
visible: (context) => context.organization?.subscription != null,
|
visible: ({ organization }) => organization?.subscription != null,
|
||||||
sections: [],
|
sections: [],
|
||||||
onPress: (context) =>
|
onPress: (context) =>
|
||||||
context.queryClient
|
context.queryClient
|
||||||
@ -312,6 +313,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
settingsTab: SettingsTabType.members,
|
settingsTab: SettingsTabType.members,
|
||||||
icon: PeopleIcon,
|
icon: PeopleIcon,
|
||||||
organizationOnly: true,
|
organizationOnly: true,
|
||||||
|
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||||
feature: 'inviteUser',
|
feature: 'inviteUser',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
@ -325,6 +327,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
settingsTab: SettingsTabType.userGroups,
|
settingsTab: SettingsTabType.userGroups,
|
||||||
icon: PeopleSettingsIcon,
|
icon: PeopleSettingsIcon,
|
||||||
organizationOnly: true,
|
organizationOnly: true,
|
||||||
|
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||||
feature: 'userGroups',
|
feature: 'userGroups',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
@ -391,6 +394,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
settingsTab: SettingsTabType.activityLog,
|
settingsTab: SettingsTabType.activityLog,
|
||||||
icon: LogIcon,
|
icon: LogIcon,
|
||||||
organizationOnly: true,
|
organizationOnly: true,
|
||||||
|
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
nameId: 'activityLogSettingsSection',
|
nameId: 'activityLogSettingsSection',
|
||||||
@ -470,6 +474,7 @@ export interface SettingsInputEntryData {
|
|||||||
readonly setValue: (context: SettingsContext, value: string) => Promise<void>
|
readonly setValue: (context: SettingsContext, value: string) => Promise<void>
|
||||||
readonly validate?: (value: string, context: SettingsContext) => string | true
|
readonly validate?: (value: string, context: SettingsContext) => string | true
|
||||||
readonly getEditable: (context: SettingsContext) => boolean
|
readonly getEditable: (context: SettingsContext) => boolean
|
||||||
|
readonly getVisible?: (context: SettingsContext) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
@ -56,7 +56,7 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
|
|||||||
: <div key={name} className="flex flex-col items-start">
|
: <div key={name} className="flex flex-col items-start">
|
||||||
<Header
|
<Header
|
||||||
id={`${name}_header`}
|
id={`${name}_header`}
|
||||||
className="mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-[13.5px] font-bold leading-cozy"
|
className="z-1 mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-[13.5px] font-bold leading-cozy"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Header>
|
</Header>
|
||||||
|
@ -86,6 +86,7 @@ export default class LocalStorage {
|
|||||||
set<K extends LocalStorageKey>(key: K, value: LocalStorageData[K]) {
|
set<K extends LocalStorageKey>(key: K, value: LocalStorageData[K]) {
|
||||||
this.values[key] = value
|
this.values[key] = value
|
||||||
this.eventTarget.dispatchEvent(new Event(key))
|
this.eventTarget.dispatchEvent(new Event(key))
|
||||||
|
this.eventTarget.dispatchEvent(new Event('_change'))
|
||||||
this.save()
|
this.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +97,7 @@ export default class LocalStorage {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
delete this.values[key]
|
delete this.values[key]
|
||||||
this.eventTarget.dispatchEvent(new Event(key))
|
this.eventTarget.dispatchEvent(new Event(key))
|
||||||
|
this.eventTarget.dispatchEvent(new Event('_change'))
|
||||||
this.save()
|
this.save()
|
||||||
return oldValue
|
return oldValue
|
||||||
}
|
}
|
||||||
@ -114,13 +116,23 @@ export default class LocalStorage {
|
|||||||
key: K,
|
key: K,
|
||||||
callback: (value: LocalStorageData[K] | undefined) => void,
|
callback: (value: LocalStorageData[K] | undefined) => void,
|
||||||
) {
|
) {
|
||||||
const wrapped = () => {
|
const onChange = () => {
|
||||||
const value = this.values[key]
|
callback(this.values[key])
|
||||||
callback(value)
|
|
||||||
}
|
}
|
||||||
this.eventTarget.addEventListener(key, wrapped)
|
this.eventTarget.addEventListener(key, onChange)
|
||||||
return () => {
|
return () => {
|
||||||
this.eventTarget.removeEventListener(key, wrapped)
|
this.eventTarget.removeEventListener(key, onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an event listener to all keys. */
|
||||||
|
subscribeAll(callback: (value: Partial<LocalStorageData>) => void) {
|
||||||
|
const onChange = () => {
|
||||||
|
callback(this.values)
|
||||||
|
}
|
||||||
|
this.eventTarget.addEventListener('_change', onChange)
|
||||||
|
return () => {
|
||||||
|
this.eventTarget.removeEventListener('_change', onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,6 +65,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
|||||||
naming: ['"Enso Naming"', '"Enso"', '"M PLUS 1"'],
|
naming: ['"Enso Naming"', '"Enso"', '"M PLUS 1"'],
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
|
'2xs': '10.5px',
|
||||||
xs: '11.5px',
|
xs: '11.5px',
|
||||||
sm: '13px',
|
sm: '13px',
|
||||||
xl: '19px',
|
xl: '19px',
|
||||||
@ -344,7 +345,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
|||||||
},
|
},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
1: '1',
|
1: '1',
|
||||||
3: '3',
|
tooltip: '2',
|
||||||
},
|
},
|
||||||
backdropBlur: {
|
backdropBlur: {
|
||||||
xs: '2px',
|
xs: '2px',
|
||||||
|
@ -505,6 +505,21 @@ export interface CreateCustomerPortalSessionResponse {
|
|||||||
readonly url: string | null
|
readonly url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the user is on a plan associated with an organization. */
|
||||||
|
export function isUserOnPlanWithOrganization(user: User) {
|
||||||
|
switch (user.plan) {
|
||||||
|
case undefined:
|
||||||
|
case Plan.free:
|
||||||
|
case Plan.solo: {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case Plan.team:
|
||||||
|
case Plan.enterprise: {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether an {@link AssetPermission} is a {@link UserPermission}. */
|
/** Whether an {@link AssetPermission} is a {@link UserPermission}. */
|
||||||
export function isUserPermission(permission: AssetPermission): permission is UserPermission {
|
export function isUserPermission(permission: AssetPermission): permission is UserPermission {
|
||||||
return 'user' in permission
|
return 'user' in permission
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
"missingVerificationCodeError": "Missing verification code",
|
"missingVerificationCodeError": "Missing verification code",
|
||||||
"passwordMismatchError": "Passwords do not match.",
|
"passwordMismatchError": "Passwords do not match.",
|
||||||
"passwordLengthError": "Your password must be between 6 and 256 characters long.",
|
"passwordLengthError": "Your password must be between 6 and 256 characters long.",
|
||||||
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols.",
|
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols ( ^$*.[]{}()?\"!@#%&/,><':;|_~`=+-).",
|
||||||
"passwordValidationError": "Your password does not meet the security requirements.",
|
"passwordValidationError": "Your password does not meet the security requirements.",
|
||||||
|
|
||||||
"confirmSignUpError": "Incorrect email or confirmation code.",
|
"confirmSignUpError": "Incorrect email or confirmation code.",
|
||||||
@ -462,6 +462,8 @@
|
|||||||
"organizationInviteSuffix": "' is inviting you. Would you like to accept? All your assets will be moved with you to your personal space.",
|
"organizationInviteSuffix": "' is inviting you. Would you like to accept? All your assets will be moved with you to your personal space.",
|
||||||
"organizationInviteErrorSuffix": "' is inviting you.",
|
"organizationInviteErrorSuffix": "' is inviting you.",
|
||||||
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at",
|
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at",
|
||||||
|
"youHaveNoUserGroupsAdmin": "This organization has no user groups. You can create one using the button above.",
|
||||||
|
"youHaveNoUserGroupsNonAdmin": "This organization has no user groups. You can create one using the button above.",
|
||||||
|
|
||||||
"enableMultitabs": "Enable Multi-Tabs",
|
"enableMultitabs": "Enable Multi-Tabs",
|
||||||
"enableMultitabsDescription": "Open multiple projects at the same time.",
|
"enableMultitabsDescription": "Open multiple projects at the same time.",
|
||||||
|
Loading…
Reference in New Issue
Block a user