Dashboard improvements (#10715)

- Frontend part of https://github.com/enso-org/cloud-v2/issues/1397
- Show organization details to everyone (behavior unchanged)
- ⚠️ Allow editing only for admins
- ⚠️ Currently there is no backend endpoint to get organization permissions
- Stop (incorrectly) submitting *all* settings inputs twice
- Frontend part of https://github.com/enso-org/cloud-v2/issues/1396
- Fix "remove invitation" sending wrong request
- Stop sending `organizationId` in "create invitation" request
- Not adding `email` autocomplete to `/registration`
- Currently already exists
- but it will need to be revisited after the new sign up flow PR is merged.
- Fix https://github.com/enso-org/cloud-v2/issues/1407
- Fix project open request being sent multiple times
- Address https://github.com/enso-org/enso/issues/10633#issuecomment-2252540802
- Fix path to local projects (previously gave the path to their containing folder

Other fixes:
- Various fixes for autocomplete:
- Fix autocomplete appearance (dropdown is no longer detached from main input)
- Add tooltips for overflowing autocomplete entries
- Add tooltips for overflowing usernames in "manage permissions" modal
- Animate height of "asset search bar" dropdown and "autocomplete" dropdown
- Auto-size names of object keys in Datalink input

Other changes:
- Avoid gap with missing background on right side of tab bar when resizing window due to the clip path being animated
- Add <kbd>Cmd</kbd>+<kbd>W</kbd> and <kbd>Cmd</kbd>+<kbd>Option</kbd>+<kbd>W</kbd> to close tab
- Make <kbd>Escape</kbd> only close tab if it is the Settings tab (a temporary tab)

# Important Notes
None
This commit is contained in:
somebody1234 2024-08-01 21:29:05 +10:00 committed by GitHub
parent a5922e0844
commit d9fc3a0fb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 663 additions and 544 deletions

View File

@ -225,7 +225,7 @@ export function locateNotEnabledStub(page: test.Locator | test.Page) {
/** Find a "new folder" icon (if any) on the current page. */ /** Find a "new folder" icon (if any) on the current page. */
export function locateNewFolderIcon(page: test.Locator | test.Page) { export function locateNewFolderIcon(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'New Folder' }) return page.getByRole('button', { name: 'New Folder', exact: true })
} }
/** Find a "new secret" icon (if any) on the current page. */ /** Find a "new secret" icon (if any) on the current page. */

View File

@ -226,7 +226,7 @@ export default class DrivePageActions extends PageActions {
/** Create a new folder using the icon in the Drive Bar. */ /** Create a new folder using the icon in the Drive Bar. */
createFolder() { createFolder() {
return this.step('Create folder', (page) => return this.step('Create folder', (page) =>
page.getByRole('button', { name: 'New Folder' }).click(), page.getByRole('button', { name: 'New Folder', exact: true }).click(),
) )
} }

View File

@ -79,6 +79,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
rootDirectoryId: defaultDirectoryId, rootDirectoryId: defaultDirectoryId,
userGroups: null, userGroups: null,
plan: backend.Plan.enterprise, plan: backend.Plan.enterprise,
isOrganizationAdmin: true,
} }
const defaultOrganization: backend.OrganizationInfo = { const defaultOrganization: backend.OrganizationInfo = {
id: defaultOrganizationId, id: defaultOrganizationId,
@ -259,6 +260,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
isEnabled: true, isEnabled: true,
userGroups: null, userGroups: null,
plan: backend.Plan.enterprise, plan: backend.Plan.enterprise,
isOrganizationAdmin: true,
...rest, ...rest,
} }
users.push(user) users.push(user)
@ -733,6 +735,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
rootDirectoryId, rootDirectoryId,
userGroups: null, userGroups: null,
plan: backend.Plan.enterprise, plan: backend.Plan.enterprise,
isOrganizationAdmin: true,
} }
await route.fulfill({ json: currentUser }) await route.fulfill({ json: currentUser })
}) })

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.3" d="M2 8C2 5.79086 3.79086 4 6 4H10C12.2091 4 14 5.79086 14 8V12H2V8Z" fill="black"/>
<rect x="14.4102" y="1" width="2" height="7.65112" rx="1" transform="rotate(45 14.4102 1)" fill="black"/>
<rect x="15.8224" y="6.41211" width="2" height="7.64835" rx="1" transform="rotate(135 15.8224 6.41211)" fill="black"/>
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M15 10.7655C14.4153 10.1518 14 9.26279 14 8V12H15V10.7655Z" fill="black"/>
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M1 10.7655C1.58473 10.1518 2 9.26279 2 8V12H1V10.7655Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 705 B

View File

@ -20,6 +20,7 @@ export interface TextProps
readonly lineClamp?: number readonly lineClamp?: number
readonly tooltip?: React.ReactElement | string | false | null readonly tooltip?: React.ReactElement | string | false | null
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display'] readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
readonly tooltipPlacement?: aria.Placement
} }
export const TEXT_STYLE = twv.tv({ export const TEXT_STYLE = twv.tv({
@ -136,6 +137,7 @@ export const Text = React.forwardRef(function Text(
elementType: ElementType = 'span', elementType: ElementType = 'span',
tooltip: tooltipElement = children, tooltip: tooltipElement = children,
tooltipDisplay = 'whenOverflowing', tooltipDisplay = 'whenOverflowing',
tooltipPlacement,
textSelection, textSelection,
disableLineHeightCompensation = false, disableLineHeightCompensation = false,
...ariaProps ...ariaProps
@ -178,6 +180,7 @@ export const Text = React.forwardRef(function Text(
targetRef: textElementRef, targetRef: textElementRef,
display: tooltipDisplay, display: tooltipDisplay,
children: tooltipElement, children: tooltipElement,
...(tooltipPlacement ? { overlayPositionProps: { placement: tooltipPlacement } } : {}),
}) })
return ( return (

View File

@ -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-words', base: 'group flex justify-center items-center text-center text-balance break-words z-50',
variants: { variants: {
variant: { variant: {
custom: '', custom: '',

View File

@ -1,10 +1,14 @@
/** @file A select menu with a dropdown. */ /** @file A select menu with a dropdown. */
import * as React from 'react' import * as React from 'react'
import CloseIcon from '#/assets/cross.svg'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
import Input from '#/components/styled/Input' import Input from '#/components/styled/Input'
import { Button, Text } from '#/components/AriaComponents'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import { twMerge } from 'tailwind-merge'
// ================= // =================
// === Constants === // === Constants ===
@ -39,7 +43,7 @@ interface InternalBaseAutocompleteProps<T> {
interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> { interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
/** Whether selecting multiple values is allowed. */ /** Whether selecting multiple values is allowed. */
readonly multiple?: false readonly multiple?: false
readonly setValues: (value: [T]) => void readonly setValues: (value: readonly [] | readonly [T]) => void
readonly itemsToString?: never readonly itemsToString?: never
} }
@ -76,7 +80,8 @@ export type AutocompleteProps<T> = (
/** A select menu with a dropdown. */ /** A select menu with a dropdown. */
export default function Autocomplete<T>(props: AutocompleteProps<T>) { export default function Autocomplete<T>(props: AutocompleteProps<T>) {
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
const { text, setText, autoFocus, items, itemToKey, itemToString, itemsToString, matches } = props const { text, setText, autoFocus = false, items, itemToKey, itemToString, itemsToString } = props
const { matches } = props
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false) const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null) const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const valuesSet = React.useMemo(() => new Set(values), [values]) const valuesSet = React.useMemo(() => new Set(values), [values])
@ -181,72 +186,83 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
} }
return ( return (
<div onKeyDown={onKeyDown} className="grow"> <div className="relative h-6 w-full">
<FocusRing within> <div
<div className="flex flex-1 rounded-full"> onKeyDown={onKeyDown}
{canEditText ? className={twMerge(
<Input 'absolute w-full grow transition-colors',
type={type} isDropdownVisible && matchingItems.length !== 0 ?
ref={inputRef} 'before:absolute before:inset-0 before:z-1 before:rounded-xl before:border-0.5 before:border-primary/20 before:bg-frame before:shadow-soft before:backdrop-blur-default'
autoFocus={autoFocus} : '',
size={1} )}
value={text ?? ''} >
autoComplete="off" <FocusRing within>
placeholder={placeholder == null ? placeholder : placeholder} <div className="relative z-1 flex flex-1 rounded-full">
className="text grow rounded-full bg-transparent px-button-x" {canEditText ?
onFocus={() => { <Input
setIsDropdownVisible(true) type={type}
}} ref={inputRef}
onBlur={() => { autoFocus={autoFocus}
window.setTimeout(() => { size={1}
setIsDropdownVisible(false) value={text ?? ''}
}) autoComplete="off"
}} placeholder={placeholder == null ? placeholder : placeholder}
onChange={(event) => { className="text grow rounded-full bg-transparent px-button-x"
setIsDropdownVisible(true) onFocus={() => {
setText(event.currentTarget.value === '' ? null : event.currentTarget.value) setIsDropdownVisible(true)
}}
onBlur={() => {
window.setTimeout(() => {
setIsDropdownVisible(false)
})
}}
onChange={(event) => {
setIsDropdownVisible(true)
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
}}
/>
: <div
tabIndex={-1}
className="text grow cursor-pointer whitespace-nowrap bg-transparent px-button-x"
onClick={() => {
setIsDropdownVisible(true)
}}
onBlur={() => {
window.setTimeout(() => {
setIsDropdownVisible(false)
})
}}
>
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
</div>
}
<Button
size="medium"
variant="icon"
icon={CloseIcon}
className="absolute right-1 top-1/2 -translate-y-1/2"
onPress={() => {
setValues([])
// setIsDropdownVisible(true)
setText?.('')
}} }}
/> />
: <div </div>
ref={(element) => element?.focus()} </FocusRing>
tabIndex={-1}
className="text grow cursor-pointer whitespace-nowrap bg-transparent px-button-x"
onClick={() => {
setIsDropdownVisible(true)
}}
onBlur={() => {
requestAnimationFrame(() => {
setIsDropdownVisible(false)
})
}}
>
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
</div>
}
</div>
</FocusRing>
<div className="h">
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'relative top-2 z-1 h-max w-full rounded-default shadow-soft before:absolute before:top before:h-full before:w-full before:rounded-default before:bg-frame before:backdrop-blur-default', 'relative z-1 grid h-max w-full rounded-b-xl transition-grid-template-rows duration-200',
isDropdownVisible && isDropdownVisible && matchingItems.length !== 0 ? 'grid-rows-1fr' : 'grid-rows-0fr',
matchingItems.length !== 0 &&
'before:border before:border-primary/10',
)} )}
> >
<div <div className="relative max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-b-xl">
className={tailwindMerge.twMerge(
'relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default',
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h-0',
)}
>
{/* FIXME: "Invite" modal does not take into account the height of the autocomplete, {/* FIXME: "Invite" modal does not take into account the height of the autocomplete,
* so the suggestions may go offscreen. */} * so the suggestions may go offscreen. */}
{matchingItems.map((item, index) => ( {matchingItems.map((item, index) => (
<div <div
key={itemToKey(item)} key={itemToKey(item)}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'text relative cursor-pointer whitespace-nowrap px-input-x first:rounded-t-default last:rounded-b-default hover:bg-hover-bg', 'text relative cursor-pointer whitespace-nowrap px-input-x last:rounded-b-xl hover:bg-hover-bg',
valuesSet.has(item) && 'bg-hover-bg', valuesSet.has(item) && 'bg-hover-bg',
index === selectedIndex && 'bg-black/5', index === selectedIndex && 'bg-black/5',
)} )}
@ -258,7 +274,9 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
toggleValue(item) toggleValue(item)
}} }}
> >
{itemToString(item)} <Text truncate="1" className="w-full" tooltipPlacement="left">
{itemToString(item)}
</Text>
</div> </div>
))} ))}
</div> </div>

View File

@ -204,7 +204,7 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
> >
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 before:backdrop-blur-default before:transition-colors', 'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:backdrop-blur-default before:transition-colors',
isDropdownVisible ? isDropdownVisible ?
'before:h-full before:shadow-soft' 'before:h-full before:shadow-soft'
: 'before:h-text group-hover:before:bg-hover-bg', : 'before:h-text group-hover:before:bg-hover-bg',

View File

@ -12,6 +12,7 @@ import Checkbox from '#/components/styled/Checkbox'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
import { useBackendQuery } from '#/hooks/backendHooks'
import * as jsonSchema from '#/utilities/jsonSchema' import * as jsonSchema from '#/utilities/jsonSchema'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -38,13 +39,19 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const { value, setValue } = props const { value, setValue } = props
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin, // The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
// but it is more convenient to avoid having plugin infrastructure. // but it is more convenient to avoid having plugin infrastructure.
const remoteBackend = backendProvider.useRemoteBackend() const remoteBackend = backendProvider.useRemoteBackendStrict()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const [autocompleteText, setAutocompleteText] = React.useState(() => const [autocompleteText, setAutocompleteText] = React.useState(() =>
typeof value === 'string' ? value : null, typeof value === 'string' ? value : null,
) )
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null) const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
const [autocompleteItems, setAutocompleteItems] = React.useState<string[] | null>(null) const isSecret =
'type' in schema &&
schema.type === 'string' &&
'format' in schema &&
schema.format === 'enso-secret'
const { data: secrets } = useBackendQuery(remoteBackend, 'listSecrets', [], { enabled: isSecret })
const autocompleteItems = isSecret ? secrets?.map((secret) => secret.path) ?? null : null
// NOTE: `enum` schemas omitted for now as they are not yet used. // NOTE: `enum` schemas omitted for now as they are not yet used.
if ('const' in schema) { if ('const' in schema) {
@ -57,18 +64,11 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
case 'string': { case 'string': {
if ('format' in schema && schema.format === 'enso-secret') { if ('format' in schema && schema.format === 'enso-secret') {
const isValid = typeof value === 'string' && value !== '' const isValid = typeof value === 'string' && value !== ''
if (autocompleteItems == null) {
setAutocompleteItems([])
void (async () => {
const secrets = (await remoteBackend?.listSecrets()) ?? []
setAutocompleteItems(secrets.map((secret) => secret.path))
})()
}
children.push( children.push(
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'grow rounded-default border', 'w-60 rounded-default border-0.5',
isValid ? 'border-primary/10' : 'border-red-700/60', isValid ? 'border-primary/20' : 'border-red-700/60',
)} )}
> >
<Autocomplete <Autocomplete
@ -79,7 +79,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())} matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
values={isValid ? [value] : []} values={isValid ? [value] : []}
setValues={(values) => { setValues={(values) => {
setValue(values[0]) setValue(values[0] ?? '')
}} }}
text={autocompleteText} text={autocompleteText}
setText={setAutocompleteText} setText={setAutocompleteText}
@ -97,8 +97,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'string' ? value : ''} value={typeof value === 'string' ? value : ''}
size={1} size={1}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only', 'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60', getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)} )}
placeholder={getText('enterText')} placeholder={getText('enterText')}
onChange={(event) => { onChange={(event) => {
@ -125,8 +125,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''} value={typeof value === 'number' ? value : ''}
size={1} size={1}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only', 'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60', getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)} )}
placeholder={getText('enterNumber')} placeholder={getText('enterNumber')}
onChange={(event) => { onChange={(event) => {
@ -154,8 +154,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''} value={typeof value === 'number' ? value : ''}
size={1} size={1}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only', 'focus-child min-6- text40 w-80 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60', getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)} )}
placeholder={getText('enterInteger')} placeholder={getText('enterInteger')}
onChange={(event) => { onChange={(event) => {
@ -195,19 +195,13 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
) )
if (jsonSchema.constantValue(defs, schema).length !== 1) { if (jsonSchema.constantValue(defs, schema).length !== 1) {
children.push( children.push(
<div className="flex flex-col gap-json-schema rounded-default border border-primary/10 p-json-schema-object-input"> <div className="grid items-center gap-json-schema rounded-default border-0.5 border-primary/20 p-json-schema-object-input">
{propertyDefinitions.map((definition) => { {propertyDefinitions.map((definition) => {
const { key, schema: childSchema } = definition const { key, schema: childSchema } = definition
const isOptional = !requiredProperties.includes(key) const isOptional = !requiredProperties.includes(key)
return jsonSchema.constantValue(defs, childSchema).length === 1 ? return jsonSchema.constantValue(defs, childSchema).length === 1 ?
null null
: <div : <>
key={key}
className="flex flex-wrap items-center gap-2"
{...('description' in childSchema ?
{ title: String(childSchema.description) }
: {})}
>
<FocusArea active={isOptional} direction="horizontal"> <FocusArea active={isOptional} direction="horizontal">
{(innerProps) => { {(innerProps) => {
const isPresent = value != null && key in value const isPresent = value != null && key in value
@ -218,7 +212,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
isDisabled={!isOptional} isDisabled={!isOptional}
isActive={!isOptional || isPresent} isActive={!isOptional || isPresent}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left', 'text col-start-1 inline-block whitespace-nowrap rounded-full px-button-x text-left',
isOptional && 'hover:bg-hover-bg', isOptional && 'hover:bg-hover-bg',
)} )}
onPress={() => { onPress={() => {
@ -254,45 +248,47 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
}} }}
</FocusArea> </FocusArea>
{value != null && key in value && ( {value != null && key in value && (
<JSONSchemaInput <div className="col-start-2">
readOnly={readOnly} <JSONSchemaInput
defs={defs} readOnly={readOnly}
schema={childSchema} defs={defs}
path={`${path}/properties/${key}`} schema={childSchema}
getValidator={getValidator} path={`${path}/properties/${key}`}
// This is SAFE, as `value` is an untyped object. getValidator={getValidator}
// eslint-disable-next-line no-restricted-syntax // This is SAFE, as `value` is an untyped object.
value={(value as Record<string, unknown>)[key] ?? null} // eslint-disable-next-line no-restricted-syntax
setValue={(newValue) => { value={(value as Record<string, unknown>)[key] ?? null}
setValue((oldValue) => { setValue={(newValue) => {
if (typeof newValue === 'function') { setValue((oldValue) => {
const unsafeValue: unknown = newValue( if (typeof newValue === 'function') {
// This is SAFE; but there is no way to tell TypeScript that an object const unsafeValue: unknown = newValue(
// has an index signature.
// eslint-disable-next-line no-restricted-syntax
(oldValue as Readonly<Record<string, unknown>>)[key] ?? null,
)
// The value MAY be `null`, but it is better than the value being a
// function (which is *never* the intended result).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
newValue = unsafeValue!
}
return (
typeof oldValue === 'object' &&
oldValue != null &&
// This is SAFE; but there is no way to tell TypeScript that an object // This is SAFE; but there is no way to tell TypeScript that an object
// has an index signature. // has an index signature.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
(oldValue as Readonly<Record<string, unknown>>)[key] === (oldValue as Readonly<Record<string, unknown>>)[key] ?? null,
newValue )
) ? // The value MAY be `null`, but it is better than the value being a
oldValue // function (which is *never* the intended result).
: { ...oldValue, [key]: newValue } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
}) newValue = unsafeValue!
}} }
/> return (
typeof oldValue === 'object' &&
oldValue != null &&
// This is SAFE; but there is no way to tell TypeScript that an object
// has an index signature.
// eslint-disable-next-line no-restricted-syntax
(oldValue as Readonly<Record<string, unknown>>)[key] ===
newValue
) ?
oldValue
: { ...oldValue, [key]: newValue }
})
}}
/>
</div>
)} )}
</div> </>
})} })}
</div>, </div>,
) )

View File

@ -44,6 +44,7 @@ export const ACTION_TO_TEXT_ID: Readonly<
> >
> = { > = {
settings: 'settingsShortcut', settings: 'settingsShortcut',
closeTab: 'closeTabShortcut',
open: 'openShortcut', open: 'openShortcut',
run: 'runShortcut', run: 'runShortcut',
close: 'closeShortcut', close: 'closeShortcut',

View File

@ -1,8 +1,8 @@
/** @file A styled submit button. */ /** @file A styled submit button. */
import * as React from 'react' import * as React from 'react'
import type * as aria from '#/components/aria' import { Button } from '#/components/AriaComponents'
import * as ariaComponents from '#/components/AriaComponents' import { submitForm } from '#/utilities/event'
// ==================== // ====================
// === SubmitButton === // === SubmitButton ===
@ -14,15 +14,14 @@ export interface SubmitButtonProps {
readonly isDisabled?: boolean readonly isDisabled?: boolean
readonly text: string readonly text: string
readonly icon: string readonly icon: string
readonly onPress: (event: aria.PressEvent) => void
} }
/** A styled submit button. */ /** A styled submit button. */
export default function SubmitButton(props: SubmitButtonProps) { export default function SubmitButton(props: SubmitButtonProps) {
const { isDisabled = false, text, icon, onPress, isLoading } = props const { isDisabled = false, text, icon, isLoading } = props
return ( return (
<ariaComponents.Button <Button
size="large" size="large"
fullWidth fullWidth
variant="submit" variant="submit"
@ -33,9 +32,9 @@ export default function SubmitButton(props: SubmitButtonProps) {
icon={icon} icon={icon}
iconPosition="end" iconPosition="end"
rounded="full" rounded="full"
onPress={onPress} onPress={submitForm}
> >
{text} {text}
</ariaComponents.Button> </Button>
) )
} }

View File

@ -37,6 +37,7 @@ import * as backendModule from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend' import * as localBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager' import * as projectManager from '#/services/ProjectManager'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import type * as assetTreeNode from '#/utilities/AssetTreeNode' import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime' import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download' import * as download from '#/utilities/download'
@ -49,6 +50,7 @@ import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set' import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility' import Visibility from '#/utilities/Visibility'
import { useQuery } from '@tanstack/react-query'
// ================= // =================
// === Constants === // === Constants ===
@ -154,6 +156,14 @@ export default function AssetRow(props: AssetRowProps) {
const openProjectMutate = openProjectMutation.mutateAsync const openProjectMutate = openProjectMutation.mutateAsync
const closeProjectMutate = closeProjectMutation.mutateAsync const closeProjectMutate = closeProjectMutation.mutateAsync
const { data: projectState } = useQuery({
// This is SAFE, as `isOpened` is only true for projects.
// eslint-disable-next-line no-restricted-syntax
...createGetProjectDetailsQuery.createPassiveListener(item.item.id as backendModule.ProjectId),
select: (data) => data.state.type,
enabled: item.type === backendModule.AssetType.project,
})
const setSelected = useEventCallback((newSelected: boolean) => { const setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState() const { selectedKeys } = driveStore.getState()
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected)) setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
@ -798,7 +808,12 @@ export default function AssetRow(props: AssetRowProps) {
} }
}} }}
onDragStart={(event) => { onDragStart={(event) => {
if (rowState.isEditingName) { if (
rowState.isEditingName ||
(projectState !== backendModule.ProjectState.closed &&
projectState !== backendModule.ProjectState.created &&
projectState != null)
) {
event.preventDefault() event.preventDefault()
} else { } else {
props.onDragStart?.(event) props.onDragStart?.(event)

View File

@ -8,13 +8,13 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import PermissionSelector from '#/components/dashboard/PermissionSelector' import PermissionSelector from '#/components/dashboard/PermissionSelector'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import { Text } from '#/components/AriaComponents'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
// ================= // =================
@ -85,7 +85,7 @@ export default function Permission(props: PermissionProps) {
return ( return (
<FocusArea active={!isDisabled} direction="horizontal"> <FocusArea active={!isDisabled} direction="horizontal">
{(innerProps) => ( {(innerProps) => (
<div className="flex items-center gap-user-permission" {...innerProps}> <div className="flex w-full items-center gap-user-permission" {...innerProps}>
<PermissionSelector <PermissionSelector
showDelete showDelete
isDisabled={isDisabled} isDisabled={isDisabled}
@ -100,7 +100,7 @@ export default function Permission(props: PermissionProps) {
doDelete(backendModule.getAssetPermissionId(permission)) doDelete(backendModule.getAssetPermissionId(permission))
}} }}
/> />
<aria.Text className="text">{backendModule.getAssetPermissionName(permission)}</aria.Text> <Text truncate="1">{backendModule.getAssetPermissionName(permission)}</Text>
</div> </div>
)} )}
</FocusArea> </FocusArea>

View File

@ -18,7 +18,7 @@ export default function Separator(props: SeparatorProps) {
return ( return (
!hidden && ( !hidden && (
<aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-[0.5px] border-black/[0.16]" /> <aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-0.5 border-black/[0.16]" />
) )
) )
} }

View File

@ -11,6 +11,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import Button from '#/components/styled/Button' import Button from '#/components/styled/Button'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
import { twMerge } from '#/utilities/tailwindMerge'
// ===================== // =====================
// === SettingsInput === // === SettingsInput ===
@ -61,8 +62,10 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()( {...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
{ {
ref, ref,
className: className: twMerge(
'w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame', 'w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame px-1 border-0.5 border-transparent',
!isDisabled && 'border-primary/20',
),
...(type == null ? {} : { type: isShowingPassword ? 'text' : type }), ...(type == null ? {} : { type: isShowingPassword ? 'text' : type }),
disabled: isDisabled, disabled: isDisabled,
size: 1, size: 1,

View File

@ -10,6 +10,7 @@ import ArrowLeftIcon from '#/assets/arrow_left.svg'
import ArrowRightIcon from '#/assets/arrow_right.svg' import ArrowRightIcon from '#/assets/arrow_right.svg'
import CameraIcon from '#/assets/camera.svg' import CameraIcon from '#/assets/camera.svg'
import CloseIcon from '#/assets/close.svg' import CloseIcon from '#/assets/close.svg'
import CloseTabIcon from '#/assets/close_tab.svg'
import CloudToIcon from '#/assets/cloud_to.svg' import CloudToIcon from '#/assets/cloud_to.svg'
import CopyIcon from '#/assets/copy.svg' import CopyIcon from '#/assets/copy.svg'
import CopyAsPathIcon from '#/assets/copy_as_path.svg' import CopyAsPathIcon from '#/assets/copy_as_path.svg'
@ -52,6 +53,8 @@ export function createBindings() {
export const BINDINGS = inputBindings.defineBindings({ export const BINDINGS = inputBindings.defineBindings({
settings: { name: 'Settings', bindings: ['Mod+,'], icon: SettingsIcon }, settings: { name: 'Settings', bindings: ['Mod+,'], icon: SettingsIcon },
// An alternative shortcut is required because Mod+W cannot be overridden in browsers.
closeTab: { name: 'Close Tab', bindings: ['Mod+W', 'Mod+Alt+W'], icon: CloseTabIcon },
open: { name: 'Open', bindings: ['Enter'], icon: OpenIcon }, open: { name: 'Open', bindings: ['Enter'], icon: OpenIcon },
run: { name: 'Execute as Task', bindings: ['Shift+Enter'], icon: Play2Icon }, run: { name: 'Execute as Task', bindings: ['Shift+Enter'], icon: Play2Icon },
close: { name: 'Close', bindings: [], icon: CloseIcon }, close: { name: 'Close', bindings: [], icon: CloseIcon },

View File

@ -168,28 +168,20 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
method: Method, method: Method,
args: Parameters<Backend[Method]>, args: Parameters<Backend[Method]>,
options?: Omit< options?: Omit<
reactQuery.UseQueryOptions< reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
Awaited<ReturnType<Backend[Method]>>, 'queryFn' | 'queryKey'
Error, > &
Awaited<ReturnType<Backend[Method]>>, Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
readonly unknown[]
>,
'queryFn'
>,
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>> ): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends backendQuery.BackendMethods>( export function useBackendQuery<Method extends backendQuery.BackendMethods>(
backend: Backend | null, backend: Backend | null,
method: Method, method: Method,
args: Parameters<Backend[Method]>, args: Parameters<Backend[Method]>,
options?: Omit< options?: Omit<
reactQuery.UseQueryOptions< reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
Awaited<ReturnType<Backend[Method]>>, 'queryFn' | 'queryKey'
Error, > &
Awaited<ReturnType<Backend[Method]>>, Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
readonly unknown[]
>,
'queryFn'
>,
): reactQuery.UseQueryResult< ): reactQuery.UseQueryResult<
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
Awaited<ReturnType<Backend[Method]>> | undefined Awaited<ReturnType<Backend[Method]>> | undefined
@ -200,21 +192,12 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
method: Method, method: Method,
args: Parameters<Backend[Method]>, args: Parameters<Backend[Method]>,
options?: Omit< options?: Omit<
reactQuery.UseQueryOptions< reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
Awaited<ReturnType<Backend[Method]>>, 'queryFn' | 'queryKey'
Error, > &
Awaited<ReturnType<Backend[Method]>>, Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
readonly unknown[]
>,
'queryFn'
>,
) { ) {
return reactQuery.useQuery< return reactQuery.useQuery<Awaited<ReturnType<Backend[Method]>>>({
Awaited<ReturnType<Backend[Method]>>,
Error,
Awaited<ReturnType<Backend[Method]>>,
readonly unknown[]
>({
...options, ...options,
...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey), ...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey),
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return

View File

@ -5,6 +5,7 @@ import * as modalProvider from '#/providers/ModalProvider'
import ContextMenu from '#/components/ContextMenu' import ContextMenu from '#/components/ContextMenu'
import ContextMenus from '#/components/ContextMenus' import ContextMenus from '#/components/ContextMenus'
import { useSyncRef } from '#/hooks/syncRefHooks'
// ====================== // ======================
// === contextMenuRef === // === contextMenuRef ===
@ -16,44 +17,52 @@ export function useContextMenuRef(
key: string, key: string,
label: string, label: string,
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => React.JSX.Element | null, createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => React.JSX.Element | null,
options: { enabled?: boolean } = {},
) { ) {
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const createEntriesRef = React.useRef(createEntries) const createEntriesRef = React.useRef(createEntries)
createEntriesRef.current = createEntries createEntriesRef.current = createEntries
const optionsRef = useSyncRef(options)
const cleanupRef = React.useRef(() => {}) const cleanupRef = React.useRef(() => {})
const [contextMenuRef] = React.useState(() => (element: HTMLElement | null) => { const contextMenuRef = React.useMemo(
cleanupRef.current() () => (element: HTMLElement | null) => {
if (element == null) { cleanupRef.current()
cleanupRef.current = () => {} if (element == null) {
} else { cleanupRef.current = () => {}
const onContextMenu = (event: MouseEvent) => { } else {
const position = { pageX: event.pageX, pageY: event.pageY } const onContextMenu = (event: MouseEvent) => {
const children = createEntriesRef.current(position) const { enabled = true } = optionsRef.current
if (children != null) { if (enabled) {
event.preventDefault() const position = { pageX: event.pageX, pageY: event.pageY }
event.stopPropagation() const children = createEntriesRef.current(position)
setModal( if (children != null) {
<ContextMenus event.preventDefault()
ref={(contextMenusElement) => { event.stopPropagation()
if (contextMenusElement != null) { setModal(
const rect = contextMenusElement.getBoundingClientRect() <ContextMenus
position.pageX = rect.left ref={(contextMenusElement) => {
position.pageY = rect.top if (contextMenusElement != null) {
} const rect = contextMenusElement.getBoundingClientRect()
}} position.pageX = rect.left
key={key} position.pageY = rect.top
event={event} }
> }}
<ContextMenu aria-label={label}>{children}</ContextMenu> key={key}
</ContextMenus>, event={event}
) >
<ContextMenu aria-label={label}>{children}</ContextMenu>
</ContextMenus>,
)
}
}
}
element.addEventListener('contextmenu', onContextMenu)
cleanupRef.current = () => {
element.removeEventListener('contextmenu', onContextMenu)
} }
} }
element.addEventListener('contextmenu', onContextMenu) },
cleanupRef.current = () => { [key, label, optionsRef, setModal],
element.removeEventListener('contextmenu', onContextMenu) )
}
}
})
return contextMenuRef return contextMenuRef
} }

View File

@ -144,11 +144,11 @@ export function useOpenProjectMutation() {
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } }) client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
void client.cancelQueries({ queryKey }) void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onError: async (_, { id }) => {
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
}, },
onSuccess: (_, { id }) =>
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
onError: (_, { id }) =>
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
}) })
} }
@ -177,7 +177,6 @@ export function useCloseProjectMutation() {
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } }) client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
void client.cancelQueries({ queryKey }) void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
}, },
onSuccess: (_, { id }) => onSuccess: (_, { id }) =>
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }), client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),

View File

@ -88,7 +88,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
category !== Category.cloud && category !== Category.local ? null category !== Category.cloud && category !== Category.local ? null
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}` : isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
: asset.type === backendModule.AssetType.project ? : asset.type === backendModule.AssetType.project ?
localBackend?.getProjectDirectoryPath(asset.id) ?? null localBackend?.getProjectPath(asset.id) ?? null
: localBackendModule.extractTypeAndId(asset.id).id : localBackendModule.extractTypeAndId(asset.id).id
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' }) const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })

View File

@ -86,7 +86,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const path = const path =
isCloud ? null isCloud ? null
: item.item.type === backendModule.AssetType.project ? : item.item.type === backendModule.AssetType.project ?
localBackend?.getProjectDirectoryPath(item.item.id) ?? null localBackend?.getProjectPath(item.item.id) ?? null
: localBackendModule.extractTypeAndId(item.item.id).id : localBackendModule.extractTypeAndId(item.item.id).id
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink') const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')

View File

@ -310,102 +310,109 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
> >
<div className="h-[32px]" /> <div className="h-[32px]" />
{areSuggestionsVisible && ( <div
<div className="relative mt-3 flex flex-col gap-3"> className={tailwindMerge.twMerge(
{/* Tags (`name:`, `modified:`, etc.) */} 'grid transition-grid-template-rows duration-200',
<Tags areSuggestionsVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
isCloud={isCloud} )}
querySource={querySource} >
query={query} <div className="overflow-y-auto overflow-x-hidden">
setQuery={setQuery} <div className="relative mt-3 flex flex-col gap-3">
/> {/* Tags (`name:`, `modified:`, etc.) */}
{/* Asset labels */} <Tags
{isCloud && labels.length !== 0 && ( isCloud={isCloud}
<div querySource={querySource}
data-testid="asset-search-labels" query={query}
className="pointer-events-auto flex gap-2 px-1.5" setQuery={setQuery}
> />
{[...labels] {/* Asset labels */}
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value)) {isCloud && labels.length !== 0 && (
.map((label) => { <div
const negated = query.negativeLabels.some((term) => data-testid="asset-search-labels"
array.shallowEqual(term, [label.value]), className="pointer-events-auto flex gap-2 px-1.5"
)
return (
<Label
key={label.id}
color={label.color}
active={
negated ||
query.labels.some((term) => array.shallowEqual(term, [label.value]))
}
negated={negated}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery((oldQuery) => {
const newQuery = oldQuery.withToggled(
'labels',
'negativeLabels',
label.value,
event.shiftKey,
)
baseQuery.current = newQuery
return newQuery
})
}}
>
{label.value}
</Label>
)
})}
</div>
)}
{/* Suggestions */}
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
{suggestions.map((suggestion, index) => (
// This should not be a `<button>`, since `render()` may output a
// tree containing a button.
<aria.Button
data-testid="asset-search-suggestion"
key={index}
ref={(el) => {
if (index === selectedIndex) {
el?.focus()
}
}}
className={tailwindMerge.twMerge(
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
selectedIndices.has(index) && 'bg-primary/10',
index === selectedIndex && 'bg-selected-frame',
)}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery(
selectedIndices.has(index) ?
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
)
if (event.shiftKey) {
setSelectedIndices(
new Set(
selectedIndices.has(index) ?
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
: [...selectedIndices, index],
),
)
} else {
setAreSuggestionsVisible(false)
}
}}
> >
<ariaComponents.Text variant="body" truncate="1" className="w-full"> {[...labels]
{suggestion.render()} .sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
</ariaComponents.Text> .map((label) => {
</aria.Button> const negated = query.negativeLabels.some((term) =>
))} array.shallowEqual(term, [label.value]),
)
return (
<Label
key={label.id}
color={label.color}
active={
negated ||
query.labels.some((term) => array.shallowEqual(term, [label.value]))
}
negated={negated}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery((oldQuery) => {
const newQuery = oldQuery.withToggled(
'labels',
'negativeLabels',
label.value,
event.shiftKey,
)
baseQuery.current = newQuery
return newQuery
})
}}
>
{label.value}
</Label>
)
})}
</div>
)}
{/* Suggestions */}
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
{suggestions.map((suggestion, index) => (
// This should not be a `<button>`, since `render()` may output a
// tree containing a button.
<aria.Button
data-testid="asset-search-suggestion"
key={index}
ref={(el) => {
if (index === selectedIndex) {
el?.focus()
}
}}
className={tailwindMerge.twMerge(
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
selectedIndices.has(index) && 'bg-primary/10',
index === selectedIndex && 'bg-selected-frame',
)}
onPress={(event) => {
querySource.current = QuerySource.internal
setQuery(
selectedIndices.has(index) ?
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
)
if (event.shiftKey) {
setSelectedIndices(
new Set(
selectedIndices.has(index) ?
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
: [...selectedIndices, index],
),
)
} else {
setAreSuggestionsVisible(false)
}
}}
>
<ariaComponents.Text variant="body" truncate="1" className="w-full">
{suggestion.render()}
</ariaComponents.Text>
</aria.Button>
))}
</div>
</div> </div>
</div> </div>
)} </div>
</div> </div>
<SvgMask <SvgMask
src={FindIcon} src={FindIcon}

View File

@ -78,6 +78,7 @@ export default function AssetVersion(props: AssetVersionProps) {
<ariaComponents.DialogTrigger> <ariaComponents.DialogTrigger>
<ariaComponents.TooltipTrigger> <ariaComponents.TooltipTrigger>
<ariaComponents.Button <ariaComponents.Button
size="medium"
variant="icon" variant="icon"
aria-label={getText('compareWithLatest')} aria-label={getText('compareWithLatest')}
icon={CompareIcon} icon={CompareIcon}
@ -94,6 +95,7 @@ export default function AssetVersion(props: AssetVersionProps) {
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup>
<ariaComponents.TooltipTrigger> <ariaComponents.TooltipTrigger>
<ariaComponents.Button <ariaComponents.Button
size="medium"
variant="icon" variant="icon"
aria-label={getText('restoreThisVersion')} aria-label={getText('restoreThisVersion')}
icon={RestoreIcon} icon={RestoreIcon}
@ -109,6 +111,7 @@ export default function AssetVersion(props: AssetVersionProps) {
</ariaComponents.TooltipTrigger> </ariaComponents.TooltipTrigger>
<ariaComponents.TooltipTrigger> <ariaComponents.TooltipTrigger>
<ariaComponents.Button <ariaComponents.Button
size="medium"
variant="icon" variant="icon"
aria-label={getText('duplicateThisVersion')} aria-label={getText('duplicateThisVersion')}
icon={DuplicateIcon} icon={DuplicateIcon}
@ -137,6 +140,7 @@ export default function AssetVersion(props: AssetVersionProps) {
{isProject && ( {isProject && (
<ariaComponents.TooltipTrigger> <ariaComponents.TooltipTrigger>
<ariaComponents.Button <ariaComponents.Button
size="medium"
variant="icon" variant="icon"
aria-label={getText('restoreThisVersion')} aria-label={getText('restoreThisVersion')}
icon={RestoreIcon} icon={RestoreIcon}
@ -149,6 +153,7 @@ export default function AssetVersion(props: AssetVersionProps) {
{isProject && ( {isProject && (
<ariaComponents.TooltipTrigger> <ariaComponents.TooltipTrigger>
<ariaComponents.Button <ariaComponents.Button
size="medium"
variant="icon" variant="icon"
aria-label={getText('duplicateThisVersion')} aria-label={getText('duplicateThisVersion')}
icon={DuplicateIcon} icon={DuplicateIcon}

View File

@ -97,60 +97,53 @@ export default function Editor(props: EditorProps) {
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always', networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
}) })
if (isOpeningFailed) { const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
// eslint-disable-next-line no-restricted-syntax const shouldRefetch = !projectQuery.isError && !projectQuery.isLoading
return (
if (!isOpeningFailed && !isOpening && isProjectClosed && shouldRefetch) {
startProject(project)
}
return isOpeningFailed ?
<errorBoundary.ErrorDisplay <errorBoundary.ErrorDisplay
error={openingError} error={openingError}
resetErrorBoundary={() => { resetErrorBoundary={() => {
startProject(project) startProject(project)
}} }}
/> />
) : <div
} className={twMerge.twJoin('contents', hidden && 'hidden')}
data-testvalue={project.id}
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed data-testid="editor"
const shouldRefetch = !(projectQuery.isError || projectQuery.isLoading) >
{(() => {
if (!isOpening && isProjectClosed && shouldRefetch) { if (projectQuery.isError) {
startProject(project) return (
} <errorBoundary.ErrorDisplay
error={projectQuery.error}
return ( resetErrorBoundary={() => projectQuery.refetch()}
<div />
className={twMerge.twJoin('contents', hidden && 'hidden')} )
data-testvalue={project.id} } else if (
data-testid="editor" projectQuery.isLoading ||
> projectQuery.data?.state.type !== backendModule.ProjectState.opened
{(() => { ) {
if (projectQuery.isError) { return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
return ( } else {
<errorBoundary.ErrorDisplay return (
error={projectQuery.error} <errorBoundary.ErrorBoundary>
resetErrorBoundary={() => projectQuery.refetch()} <suspense.Suspense>
/> <EditorInternal
) {...props}
} else if ( openedProject={projectQuery.data}
projectQuery.isLoading || backendType={project.type}
projectQuery.data?.state.type !== backendModule.ProjectState.opened />
) { </suspense.Suspense>
return <suspense.Loader loaderProps={{ minHeight: 'full' }} /> </errorBoundary.ErrorBoundary>
} else { )
return ( }
<errorBoundary.ErrorBoundary> })()}
<suspense.Suspense> </div>
<EditorInternal
{...props}
openedProject={projectQuery.data}
backendType={project.type}
/>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
)
}
})()}
</div>
)
} }
// ====================== // ======================

View File

@ -21,6 +21,8 @@ import NewLabelModal from '#/modals/NewLabelModal'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import AssetEventType from '#/events/AssetEventType'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import * as array from '#/utilities/array' import * as array from '#/utilities/array'
import type AssetQuery from '#/utilities/AssetQuery' import type AssetQuery from '#/utilities/AssetQuery'
import * as drag from '#/utilities/drag' import * as drag from '#/utilities/drag'
@ -44,9 +46,14 @@ export default function Labels(props: LabelsProps) {
const currentNegativeLabels = query.negativeLabels const currentNegativeLabels = query.negativeLabels
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const dispatchAssetEvent = useDispatchAssetEvent()
const labels = backendHooks.useBackendListTags(backend) ?? [] const labels = backendHooks.useBackendListTags(backend) ?? []
const deleteTagMutation = backendHooks.useBackendMutation(backend, 'deleteTag') const deleteTagMutation = backendHooks.useBackendMutation(backend, 'deleteTag', {
onSuccess: (_data, [, labelName]) => {
dispatchAssetEvent({ type: AssetEventType.deleteLabel, labelName })
},
})
return ( return (
<FocusArea direction="vertical"> <FocusArea direction="vertical">

View File

@ -55,6 +55,7 @@ export default function KeyboardShortcutsSettingsSection() {
<> <>
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup>
<ariaComponents.Button <ariaComponents.Button
size="medium"
variant="bar" variant="bar"
onPress={() => { onPress={() => {
setModal( setModal(

View File

@ -57,30 +57,37 @@ export default function MembersSettingsSection() {
const seatsLeft = const seatsLeft =
isUnderPaywall ? feature.meta.maxSeats - (members.length + invitations.length) : null isUnderPaywall ? feature.meta.maxSeats - (members.length + invitations.length) : null
const seatsTotal = feature.meta.maxSeats const seatsTotal = feature.meta.maxSeats
const isAdmin = user.isOrganizationAdmin
return ( return (
<> <>
<ariaComponents.ButtonGroup> {isAdmin && (
<ariaComponents.DialogTrigger> <ariaComponents.ButtonGroup>
<ariaComponents.Button variant="bar" rounded="full" size="medium"> <ariaComponents.DialogTrigger>
{getText('inviteMembers')} <ariaComponents.Button variant="bar" rounded="full" size="medium">
</ariaComponents.Button> {getText('inviteMembers')}
</ariaComponents.Button>
<InviteUsersModal /> <InviteUsersModal />
</ariaComponents.DialogTrigger> </ariaComponents.DialogTrigger>
{seatsLeft != null && ( {seatsLeft != null && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ariaComponents.Text> <ariaComponents.Text>
{seatsLeft <= 0 ? {seatsLeft <= 0 ?
getText('noSeatsLeft') getText('noSeatsLeft')
: getText('seatsLeft', seatsLeft, seatsTotal)} : getText('seatsLeft', seatsLeft, seatsTotal)}
</ariaComponents.Text> </ariaComponents.Text>
<paywall.PaywallDialogButton feature="inviteUserFull" variant="link" showIcon={false} /> <paywall.PaywallDialogButton
</div> feature="inviteUserFull"
)} variant="link"
</ariaComponents.ButtonGroup> showIcon={false}
/>
</div>
)}
</ariaComponents.ButtonGroup>
)}
<table className="table-fixed self-start rounded-rows"> <table className="table-fixed self-start rounded-rows">
<thead> <thead>
@ -103,9 +110,11 @@ export default function MembersSettingsSection() {
<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">
{getText('active')} {getText('active')}
<ariaComponents.ButtonGroup gap="small" className="mt-0.5"> {member.email !== user.email && isAdmin && (
<RemoveMemberButton backend={backend} userId={member.userId} /> <ariaComponents.ButtonGroup gap="small" className="mt-0.5">
</ariaComponents.ButtonGroup> <RemoveMemberButton backend={backend} userId={member.userId} />
</ariaComponents.ButtonGroup>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -118,20 +127,23 @@ export default function MembersSettingsSection() {
<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">
{getText('pendingInvitation')} {getText('pendingInvitation')}
<ariaComponents.ButtonGroup gap="small" className="mt-0.5"> {isAdmin && (
<ariaComponents.CopyButton <ariaComponents.ButtonGroup gap="small" className="mt-0.5">
size="custom" <ariaComponents.CopyButton
copyText={`enso://auth/registration?organization_id=${invitation.organizationId}`} size="custom"
aria-label={getText('copyInviteLink')} // eslint-disable-next-line @typescript-eslint/naming-convention
copyIcon={false} copyText={`enso://auth/registration?=${new URLSearchParams({ organization_id: invitation.organizationId }).toString()}`}
> aria-label={getText('copyInviteLink')}
{getText('copyInviteLink')} copyIcon={false}
</ariaComponents.CopyButton> >
{getText('copyInviteLink')}
</ariaComponents.CopyButton>
<ResendInvitationButton invitation={invitation} backend={backend} /> <ResendInvitationButton invitation={invitation} backend={backend} />
<RemoveInvitationButton backend={backend} email={invitation.userEmail} /> <RemoveInvitationButton backend={backend} email={invitation.userEmail} />
</ariaComponents.ButtonGroup> </ariaComponents.ButtonGroup>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -157,9 +169,8 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
const { invitation, backend } = props const { invitation, backend } = props
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const resendMutation = reactQuery.useMutation({ const resendMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
mutationKey: ['resendInvitation', invitation.userEmail], mutationKey: [invitation.userEmail],
mutationFn: (email: backendModule.EmailAddress) => backend.resendInvitation(email),
}) })
return ( return (
@ -168,7 +179,7 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
size="custom" size="custom"
loading={resendMutation.isPending} loading={resendMutation.isPending}
onPress={() => { onPress={() => {
resendMutation.mutate(invitation.userEmail) resendMutation.mutate([invitation.userEmail])
}} }}
> >
{getText('resend')} {getText('resend')}
@ -223,7 +234,7 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const removeMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', { const removeMutation = backendHooks.useBackendMutation(backend, 'deleteInvitation', {
mutationKey: [email], mutationKey: [email],
meta: { invalidates: [['listInvitations']], awaitInvalidates: true }, meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
}) })

View File

@ -27,9 +27,9 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
export interface MembersTableProps { export interface MembersTableProps {
readonly backend: Backend readonly backend: Backend
/** If `true`, initialize the users list with self to avoid needing a loading spinner. */ /** If `true`, initialize the users list with self to avoid needing a loading spinner. */
readonly populateWithSelf?: true readonly populateWithSelf?: boolean
readonly draggable?: true readonly draggable?: boolean
readonly allowDelete?: true readonly allowDelete?: boolean
} }
/** A list of members in the organization. */ /** A list of members in the organization. */

View File

@ -33,6 +33,7 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
const { nameId, getValue, setValue, validate, getEditable } = data const { nameId, getValue, setValue, validate, getEditable } = data
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const [errorMessage, setErrorMessage] = React.useState('') const [errorMessage, setErrorMessage] = React.useState('')
const isSubmitting = React.useRef(false)
const value = getValue(context) const value = getValue(context)
const isEditable = getEditable(context) const isEditable = getEditable(context)
@ -42,6 +43,9 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
key={value} key={value}
type="text" type="text"
onSubmit={(event) => { onSubmit={(event) => {
// Technically causes the form to submit twice when pressing `Enter` due to `Enter`
// also triggering the submit button.This is worked around by using a ref
// tracking whether the form is currently being submitted.
event.currentTarget.form?.requestSubmit() event.currentTarget.form?.requestSubmit()
}} }}
/> />
@ -52,14 +56,18 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
validationErrors={{ [FIELD_NAME]: errorMessage }} validationErrors={{ [FIELD_NAME]: errorMessage }}
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault() event.preventDefault()
const [[, newValue] = []] = new FormData(event.currentTarget) if (!isSubmitting.current) {
if (typeof newValue === 'string') { isSubmitting.current = true
setErrorMessage('') const [[, newValue] = []] = new FormData(event.currentTarget)
try { if (typeof newValue === 'string') {
await setValue(context, newValue) setErrorMessage('')
} catch (error) { try {
setErrorMessage(errorModule.getMessageOrToString(error)) await setValue(context, newValue)
} catch (error) {
setErrorMessage(errorModule.getMessageOrToString(error))
}
} }
isSubmitting.current = false
} }
}} }}
> >

View File

@ -17,6 +17,7 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import type * as backend from '#/services/Backend' import type * as backend from '#/services/Backend'
import { useFullUserSession } from '#/providers/AuthProvider'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
// ==================== // ====================
@ -32,8 +33,10 @@ export interface UserGroupRowProps {
/** A row representing a user group. */ /** A row representing a user group. */
export default function UserGroupRow(props: UserGroupRowProps) { export default function UserGroupRow(props: UserGroupRowProps) {
const { userGroup, doDeleteUserGroup } = props const { userGroup, doDeleteUserGroup } = props
const { user } = useFullUserSession()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const isAdmin = user.isOrganizationAdmin
const contextMenuRef = contextMenuHooks.useContextMenuRef( const contextMenuRef = contextMenuHooks.useContextMenuRef(
userGroup.id, userGroup.id,
getText('userGroupContextMenuLabel'), getText('userGroupContextMenuLabel'),
@ -53,6 +56,7 @@ export default function UserGroupRow(props: UserGroupRowProps) {
}} }}
/> />
), ),
{ enabled: isAdmin },
) )
return ( return (
@ -72,23 +76,25 @@ export default function UserGroupRow(props: UserGroupRowProps) {
</div> </div>
</aria.Cell> </aria.Cell>
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100"> <aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
<ariaComponents.Button {isAdmin && (
size="custom" <ariaComponents.Button
variant="custom" size="custom"
onPress={() => { variant="custom"
setModal( onPress={() => {
<ConfirmDeleteModal setModal(
actionText={getText('deleteUserGroupActionText', userGroup.groupName)} <ConfirmDeleteModal
doDelete={() => { actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
doDeleteUserGroup(userGroup) doDelete={() => {
}} doDeleteUserGroup(userGroup)
/>, }}
) />,
}} )
className="absolute right-full mr-4 size-4 -translate-y-1/2" }}
> className="absolute right-full mr-4 size-4 -translate-y-1/2"
<img src={Cross2} className="size-4" /> >
</ariaComponents.Button> <img src={Cross2} className="size-4" />
</ariaComponents.Button>
)}
</aria.Cell> </aria.Cell>
</aria.Row> </aria.Row>
) )

View File

@ -17,6 +17,7 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import type * as backend from '#/services/Backend' import type * as backend from '#/services/Backend'
import { useFullUserSession } from '#/providers/AuthProvider'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
// ======================== // ========================
@ -33,8 +34,10 @@ export interface UserGroupUserRowProps {
/** A row of the user groups table representing a user. */ /** A row of the user groups table representing a user. */
export default function UserGroupUserRow(props: UserGroupUserRowProps) { export default function UserGroupUserRow(props: UserGroupUserRowProps) {
const { user, userGroup, doRemoveUserFromUserGroup } = props const { user, userGroup, doRemoveUserFromUserGroup } = props
const { user: currentUser } = useFullUserSession()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const isAdmin = currentUser.isOrganizationAdmin
const contextMenuRef = contextMenuHooks.useContextMenuRef( const contextMenuRef = contextMenuHooks.useContextMenuRef(
user.userId, user.userId,
getText('userGroupUserContextMenuLabel'), getText('userGroupUserContextMenuLabel'),
@ -58,6 +61,7 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) {
}} }}
/> />
), ),
{ enabled: isAdmin },
) )
return ( return (
@ -77,28 +81,30 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) {
</div> </div>
</aria.Cell> </aria.Cell>
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100"> <aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
<ariaComponents.Button {isAdmin && (
size="custom" <ariaComponents.Button
variant="custom" size="custom"
onPress={() => { variant="custom"
setModal( onPress={() => {
<ConfirmDeleteModal setModal(
actionText={getText( <ConfirmDeleteModal
'removeUserFromUserGroupActionText', actionText={getText(
user.name, 'removeUserFromUserGroupActionText',
userGroup.groupName, user.name,
)} userGroup.groupName,
actionButtonLabel={getText('remove')} )}
doDelete={() => { actionButtonLabel={getText('remove')}
doRemoveUserFromUserGroup(user, userGroup) doDelete={() => {
}} doRemoveUserFromUserGroup(user, userGroup)
/>, }}
) />,
}} )
className="absolute right-full mr-4 size-4 -translate-y-1/2" }}
> className="absolute right-full mr-4 size-4 -translate-y-1/2"
<img src={Cross2} className="size-4" /> >
</ariaComponents.Button> <img src={Cross2} className="size-4" />
</ariaComponents.Button>
)}
</aria.Cell> </aria.Cell>
</aria.Row> </aria.Row>
) )

View File

@ -54,6 +54,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
[users], [users],
) )
const isLoading = userGroups == null || users == null const isLoading = userGroups == null || users == null
const isAdmin = user.isOrganizationAdmin
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
@ -65,6 +66,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true }) scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
const { dragAndDropHooks } = aria.useDragAndDrop({ const { dragAndDropHooks } = aria.useDragAndDrop({
isDisabled: !isAdmin,
getDropOperation: (target, types, allowedOperations) => getDropOperation: (target, types, allowedOperations) =>
( (
allowedOperations.includes('copy') && allowedOperations.includes('copy') &&
@ -132,41 +134,43 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
return ( return (
<> <>
<ariaComponents.ButtonGroup verticalAlign="center"> {isAdmin && (
{shouldDisplayPaywall && ( <ariaComponents.ButtonGroup verticalAlign="center">
<paywallComponents.PaywallDialogButton {shouldDisplayPaywall && (
feature="userGroupsFull" <paywallComponents.PaywallDialogButton
variant="bar" feature="userGroupsFull"
size="medium" variant="bar"
rounded="full" size="medium"
iconPosition="end" rounded="full"
tooltip={getText('userGroupsPaywallMessage')} iconPosition="end"
> tooltip={getText('userGroupsPaywallMessage')}
{getText('newUserGroup')} >
</paywallComponents.PaywallDialogButton> {getText('newUserGroup')}
)} </paywallComponents.PaywallDialogButton>
{!shouldDisplayPaywall && ( )}
<ariaComponents.Button {!shouldDisplayPaywall && (
size="medium" <ariaComponents.Button
variant="bar" size="medium"
onPress={(event) => { variant="bar"
const rect = event.target.getBoundingClientRect() onPress={(event) => {
const position = { pageX: rect.left, pageY: rect.top } const rect = event.target.getBoundingClientRect()
setModal(<NewUserGroupModal backend={backend} event={position} />) const position = { pageX: rect.left, pageY: rect.top }
}} setModal(<NewUserGroupModal backend={backend} event={position} />)
> }}
{getText('newUserGroup')} >
</ariaComponents.Button> {getText('newUserGroup')}
)} </ariaComponents.Button>
)}
{isUnderPaywall && ( {isUnderPaywall && (
<span className="text-xs"> <span className="text-xs">
{userGroupsLeft <= 0 ? {userGroupsLeft <= 0 ?
getText('userGroupsPaywallMessage') getText('userGroupsPaywallMessage')
: getText('userGroupsLimitMessage', userGroupsLeft)} : getText('userGroupsLimitMessage', userGroupsLeft)}
</span> </span>
)} )}
</ariaComponents.ButtonGroup> </ariaComponents.ButtonGroup>
)}
<div <div
ref={rootRef} ref={rootRef}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(

View File

@ -37,6 +37,7 @@ export default function UserRow(props: UserRowProps) {
const { user: self } = authProvider.useNonPartialUserSession() const { user: self } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const isAdmin = self.isOrganizationAdmin
const isSelf = user.userId === self.userId const isSelf = user.userId === self.userId
const doDeleteUser = isSelf ? null : doDeleteUserRaw const doDeleteUser = isSelf ? null : doDeleteUserRaw
@ -60,6 +61,7 @@ export default function UserRow(props: UserRowProps) {
}} }}
/> />
), ),
{ enabled: isAdmin },
) )
return ( return (

View File

@ -166,7 +166,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
} }
}, },
validate: (name) => (/\S/.test(name) ? true : ''), validate: (name) => (/\S/.test(name) ? true : ''),
getEditable: () => true, getEditable: (context) => context.user.isOrganizationAdmin,
}, },
{ {
type: SettingsEntryType.input, type: SettingsEntryType.input,
@ -183,7 +183,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
isEmail(email) ? true isEmail(email) ? true
: email === '' ? '' : email === '' ? ''
: context.getText('invalidEmailValidationError'), : context.getText('invalidEmailValidationError'),
getEditable: () => true, getEditable: (context) => context.user.isOrganizationAdmin,
}, },
{ {
type: SettingsEntryType.input, type: SettingsEntryType.input,
@ -196,7 +196,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
await context.updateOrganization([{ website: newWebsite }]) await context.updateOrganization([{ website: newWebsite }])
} }
}, },
getEditable: () => true, getEditable: (context) => context.user.isOrganizationAdmin,
}, },
{ {
type: SettingsEntryType.input, type: SettingsEntryType.input,
@ -208,7 +208,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
await context.updateOrganization([{ address: newLocation }]) await context.updateOrganization([{ address: newLocation }])
} }
}, },
getEditable: () => true, getEditable: (context) => context.user.isOrganizationAdmin,
}, },
], ],
}, },
@ -310,7 +310,11 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
{ {
type: SettingsEntryType.custom, type: SettingsEntryType.custom,
render: (context) => ( render: (context) => (
<MembersTable backend={context.backend} draggable populateWithSelf /> <MembersTable
backend={context.backend}
draggable={context.user.isOrganizationAdmin}
populateWithSelf
/>
), ),
}, },
], ],

View File

@ -19,6 +19,8 @@ import SvgMask from '#/components/SvgMask'
import * as backend from '#/services/Backend' import * as backend from '#/services/Backend'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
// ================= // =================
@ -84,8 +86,8 @@ export default function TabBar(props: TabBarProps) {
const tabRight = bounds.right - rootBounds.left - TAB_RADIUS_PX const tabRight = bounds.right - rootBounds.left - TAB_RADIUS_PX
const rightSegments = [ const rightSegments = [
'M 0 0', 'M 0 0',
`L ${rootBounds.width} 0`, `L ${rootBounds.width + window.outerWidth} 0`,
`L ${rootBounds.width} ${rootBounds.height}`, `L ${rootBounds.width + window.outerWidth} ${rootBounds.height}`,
`L ${tabRight + TAB_RADIUS_PX} ${rootBounds.height}`, `L ${tabRight + TAB_RADIUS_PX} ${rootBounds.height}`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${tabRight} ${rootBounds.height - TAB_RADIUS_PX}`, `A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${tabRight} ${rootBounds.height - TAB_RADIUS_PX}`,
] ]
@ -194,10 +196,11 @@ interface InternalTabProps extends Readonly<React.PropsWithChildren> {
export function Tab(props: InternalTabProps) { export function Tab(props: InternalTabProps) {
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
const { onLoadEnd } = props const { onLoadEnd } = props
const { getText } = textProvider.useText()
const inputBindings = useInputBindings()
const { setSelectedTab } = useTabBarContext() const { setSelectedTab } = useTabBarContext()
const ref = React.useRef<HTMLDivElement | null>(null) const ref = React.useRef<HTMLDivElement | null>(null)
const isLoadingRef = React.useRef(true) const isLoadingRef = React.useRef(true)
const { getText } = textProvider.useText()
const actuallyActive = isActive && !isHidden const actuallyActive = isActive && !isHidden
const [resizeObserver] = React.useState( const [resizeObserver] = React.useState(
() => () =>
@ -227,6 +230,16 @@ export function Tab(props: InternalTabProps) {
} }
}) })
React.useEffect(() => {
if (actuallyActive && onClose) {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
closeTab: onClose,
})
} else {
return
}
}, [inputBindings, actuallyActive, onClose])
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (actuallyActive && ref.current) { if (actuallyActive && ref.current) {
setSelectedTab(ref.current) setSelectedTab(ref.current)

View File

@ -78,7 +78,7 @@ export default function UserBar(props: UserBarProps) {
)} )}
{shouldShowUpgradeButton && ( {shouldShowUpgradeButton && (
<paywall.PaywallDialogButton feature={'inviteUser'} size="medium" variant="tertiary"> <paywall.PaywallDialogButton feature="inviteUser" size="medium" variant="tertiary">
{getText('invite')} {getText('invite')}
</paywall.PaywallDialogButton> </paywall.PaywallDialogButton>
)} )}

View File

@ -26,12 +26,11 @@ import * as parserUserEmails from '#/utilities/parseUserEmails'
/** Props for an {@link InviteUsersForm}. */ /** Props for an {@link InviteUsersForm}. */
export interface InviteUsersFormProps { export interface InviteUsersFormProps {
readonly onSubmitted: (emails: backendModule.EmailAddress[]) => void readonly onSubmitted: (emails: backendModule.EmailAddress[]) => void
readonly organizationId: backendModule.OrganizationId
} }
/** A modal with inputs for user email and permission level. */ /** A modal with inputs for user email and permission level. */
export function InviteUsersForm(props: InviteUsersFormProps) { export function InviteUsersForm(props: InviteUsersFormProps) {
const { onSubmitted, organizationId } = props const { onSubmitted } = props
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const backend = backendProvider.useRemoteBackendStrict() const backend = backendProvider.useRemoteBackendStrict()
const inputRef = React.useRef<HTMLDivElement>(null) const inputRef = React.useRef<HTMLDivElement>(null)
@ -146,9 +145,7 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
.filter((value): value is backendModule.EmailAddress => isEmail(value)) .filter((value): value is backendModule.EmailAddress => isEmail(value))
await Promise.all( await Promise.all(
emailsToSubmit.map((userEmail) => emailsToSubmit.map((userEmail) => inviteUserMutation.mutateAsync([{ userEmail }])),
inviteUserMutation.mutateAsync([{ userEmail, organizationId }]),
),
).then(() => { ).then(() => {
onSubmitted(emailsToSubmit) onSubmitted(emailsToSubmit)
}) })

View File

@ -67,15 +67,16 @@ function InviteUsersModalContent(props: InviteUsersModalContentProps) {
[], [],
) )
const invitationLink = `enso://auth/registration?organization_id=${organizationId}` const invitationParams = new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/naming-convention
organization_id: organizationId,
}).toString()
const invitationLink = `enso://auth/registration?${invitationParams}`
return ( return (
<> <>
{step === 'invite' && ( {step === 'invite' && (
<inviteUsersForm.InviteUsersForm <inviteUsersForm.InviteUsersForm onSubmitted={onInviteUsersFormInviteUsersFormSubmitted} />
onSubmitted={onInviteUsersFormInviteUsersFormSubmitted}
organizationId={organizationId}
/>
)} )}
{step === 'success' && ( {step === 'success' && (

View File

@ -178,12 +178,7 @@ export default function ManagePermissionsModal<
setUserAndUserGroups([]) setUserAndUserGroups([])
setEmail('') setEmail('')
if (email != null) { if (email != null) {
await inviteUserMutation.mutateAsync([ await inviteUserMutation.mutateAsync([{ userEmail: backendModule.EmailAddress(email) }])
{
organizationId: user.organizationId,
userEmail: backendModule.EmailAddress(email),
},
])
toast.toast.success(getText('inviteSuccess', email)) toast.toast.success(getText('inviteSuccess', email))
} }
} catch (error) { } catch (error) {
@ -322,7 +317,7 @@ export default function ManagePermissionsModal<
assetType={item.type} assetType={item.type}
onChange={setAction} onChange={setAction}
/> />
<div className="-mx-button-px grow"> <div className="grow">
<Autocomplete <Autocomplete
multiple multiple
autoFocus autoFocus

View File

@ -93,10 +93,15 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
<DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} /> <DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} />
</div> </div>
<ariaComponents.ButtonGroup className="relative"> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" isDisabled={!isSubmittable} onPress={doSubmit}> <ariaComponents.Button
size="medium"
variant="submit"
isDisabled={!isSubmittable}
onPress={doSubmit}
>
{getText('create')} {getText('create')}
</ariaComponents.Button> </ariaComponents.Button>
<ariaComponents.Button variant="cancel" onPress={unsetModal}> <ariaComponents.Button size="medium" variant="cancel" onPress={unsetModal}>
{getText('cancel')} {getText('cancel')}
</ariaComponents.Button> </ariaComponents.Button>
</ariaComponents.ButtonGroup> </ariaComponents.ButtonGroup>

View File

@ -67,7 +67,13 @@ export default function AuthenticationPage(props: AuthenticationPageProps) {
{heading} {heading}
{children} {children}
</div> </div>
: <form className={containerClasses} onSubmit={onSubmit}> : <form
className={containerClasses}
onSubmit={(event) => {
event.preventDefault()
onSubmit?.(event)
}}
>
{heading} {heading}
{children} {children}
</form> </form>

View File

@ -18,8 +18,6 @@ import Input from '#/components/Input'
import Link from '#/components/Link' import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton' import SubmitButton from '#/components/SubmitButton'
import * as eventModule from '#/utilities/event'
// ====================== // ======================
// === ForgotPassword === // === ForgotPassword ===
// ====================== // ======================
@ -53,11 +51,7 @@ export default function ForgotPassword() {
value={email} value={email}
setValue={setEmail} setValue={setEmail}
/> />
<SubmitButton <SubmitButton text={getText('sendLink')} icon={ArrowRightIcon} />
text={getText('sendLink')}
icon={ArrowRightIcon}
onPress={eventModule.submitForm}
/>
</AuthenticationPage> </AuthenticationPage>
) )
} }

View File

@ -26,8 +26,6 @@ import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton' import SubmitButton from '#/components/SubmitButton'
import TextLink from '#/components/TextLink' import TextLink from '#/components/TextLink'
import * as eventModule from '#/utilities/event'
// ============= // =============
// === Login === // === Login ===
// ============= // =============
@ -146,7 +144,6 @@ export default function Login() {
isLoading={isSubmitting} isLoading={isSubmitting}
text={getText('login')} text={getText('login')}
icon={ArrowRightIcon} icon={ArrowRightIcon}
onPress={eventModule.submitForm}
/> />
</form> </form>
</AuthenticationPage> </AuthenticationPage>

View File

@ -21,7 +21,6 @@ import Input from '#/components/Input'
import Link from '#/components/Link' import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton' import SubmitButton from '#/components/SubmitButton'
import * as eventModule from '#/utilities/event'
import LocalStorage from '#/utilities/LocalStorage' import LocalStorage from '#/utilities/LocalStorage'
import * as string from '#/utilities/string' import * as string from '#/utilities/string'
import * as validation from '#/utilities/validation' import * as validation from '#/utilities/validation'
@ -124,12 +123,7 @@ export default function Registration() {
value={confirmPassword} value={confirmPassword}
setValue={setConfirmPassword} setValue={setConfirmPassword}
/> />
<SubmitButton <SubmitButton isDisabled={isSubmitting} text={getText('register')} icon={CreateAccountIcon} />
isDisabled={isSubmitting}
text={getText('register')}
icon={CreateAccountIcon}
onPress={eventModule.submitForm}
/>
</AuthenticationPage> </AuthenticationPage>
) )
} }

View File

@ -23,7 +23,6 @@ import Input from '#/components/Input'
import Link from '#/components/Link' import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton' import SubmitButton from '#/components/SubmitButton'
import * as eventModule from '#/utilities/event'
import * as string from '#/utilities/string' import * as string from '#/utilities/string'
import * as validation from '#/utilities/validation' import * as validation from '#/utilities/validation'
@ -123,11 +122,7 @@ export default function ResetPassword() {
value={newPasswordConfirm} value={newPasswordConfirm}
setValue={setNewPasswordConfirm} setValue={setNewPasswordConfirm}
/> />
<SubmitButton <SubmitButton text={getText('reset')} icon={ArrowRightIcon} />
text={getText('reset')}
icon={ArrowRightIcon}
onPress={eventModule.submitForm}
/>
</AuthenticationPage> </AuthenticationPage>
) )
} }

View File

@ -173,7 +173,7 @@ function DashboardInner(props: DashboardProps) {
updateModal((oldModal) => { updateModal((oldModal) => {
if (oldModal == null) { if (oldModal == null) {
const currentPage = projectsStore.getState().page const currentPage = projectsStore.getState().page
if (array.includes(Object.values(TabType), currentPage)) { if (currentPage === TabType.settings) {
setPage(TabType.drive) setPage(TabType.drive)
} }
} }

View File

@ -361,8 +361,10 @@ export default class LocalBackend extends Backend {
name: projectManager.ProjectName(body.projectName), name: projectManager.ProjectName(body.projectName),
}) })
} }
const parentId = this.projectManager.getProjectDirectoryPath(id) const parentPath = projectManager.getDirectoryAndName(
const result = await this.projectManager.listDirectory(parentId) this.projectManager.getProjectPath(id),
).directoryPath
const result = await this.projectManager.listDirectory(parentPath)
const project = result.flatMap((listedProject) => const project = result.flatMap((listedProject) =>
( (
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry && listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
@ -588,7 +590,7 @@ export default class LocalBackend extends Backend {
const from = const from =
typeAndId.type !== backend.AssetType.project ? typeAndId.type !== backend.AssetType.project ?
typeAndId.id typeAndId.id
: this.projectManager.getProjectDirectoryPath(typeAndId.id) : this.projectManager.getProjectPath(typeAndId.id)
const fileName = fileInfo.fileName(from) const fileName = fileInfo.fileName(from)
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName) const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
await this.projectManager.moveFile(from, to) await this.projectManager.moveFile(from, to)
@ -629,9 +631,9 @@ export default class LocalBackend extends Backend {
await this.projectManager.moveFile(from, to) await this.projectManager.moveFile(from, to)
} }
/** Construct a new path using the given parent directory and a file name. */ /** Get the path of a project. */
getProjectDirectoryPath(id: backend.ProjectId) { getProjectPath(id: backend.ProjectId) {
return this.projectManager.getProjectDirectoryPath(extractTypeAndId(id).id) return this.projectManager.getProjectPath(extractTypeAndId(id).id)
} }
/** Construct a new path using the given parent directory and a file name. */ /** Construct a new path using the given parent directory and a file name. */

View File

@ -8,6 +8,7 @@ import * as backend from '#/services/Backend'
import * as appBaseUrl from '#/utilities/appBaseUrl' import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as dateTime from '#/utilities/dateTime' import * as dateTime from '#/utilities/dateTime'
import * as newtype from '#/utilities/newtype' import * as newtype from '#/utilities/newtype'
import invariant from 'tiny-invariant'
// ================= // =================
// === Constants === // === Constants ===
@ -389,6 +390,13 @@ export default class ProjectManager {
socket.close() socket.close()
} }
/** Get the path of a project. */
getProjectPath(projectId: UUID) {
const projectPath = this.internalProjectPaths.get(projectId)
invariant(projectPath, `Unknown project path for project '${projectId}'.`)
return projectPath
}
/** Get the directory path of a project. */ /** Get the directory path of a project. */
getProjectDirectoryPath(projectId: UUID) { getProjectDirectoryPath(projectId: UUID) {
const projectPath = this.internalProjectPaths.get(projectId) const projectPath = this.internalProjectPaths.get(projectId)

View File

@ -270,15 +270,7 @@ export default class RemoteBackend extends Backend {
/** Resend an invitation to a user. */ /** Resend an invitation to a user. */
override async resendInvitation(userEmail: backend.EmailAddress): Promise<void> { override async resendInvitation(userEmail: backend.EmailAddress): Promise<void> {
const response = await this.post(remoteBackendPaths.INVITATION_PATH, { await this.inviteUser({ userEmail, resend: true })
userEmail,
resend: true,
})
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'resendInvitationBackendError')
} else {
return
}
} }
/** Upload a new profile picture for the current user. */ /** Upload a new profile picture for the current user. */

View File

@ -27,9 +27,7 @@ export const DELETE_ORGANIZATION_PATH = 'organizations/me'
export const UPLOAD_ORGANIZATION_PICTURE_PATH = 'organizations/me/picture' export const UPLOAD_ORGANIZATION_PICTURE_PATH = 'organizations/me/picture'
/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */ /** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */
export const INVITE_USER_PATH = 'users/invite' export const INVITE_USER_PATH = 'users/invite'
/** /** Relative HTTP path to the "list invitations" endpoint of the Cloud backend API. */
* Relative HTTP path to the "list invitations" endpoint of the Cloud backend API.
*/
export const INVITATION_PATH = 'invitations' export const INVITATION_PATH = 'invitations'
/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */ /** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */
export const CREATE_PERMISSION_PATH = 'permissions' export const CREATE_PERMISSION_PATH = 'permissions'

View File

@ -117,8 +117,6 @@
--label-icons-gap: 0.25rem; --label-icons-gap: 0.25rem;
/* The transition duration of an arrow transitioning between two directions. */ /* The transition duration of an arrow transitioning between two directions. */
--arrow-transition-duration: 300ms; --arrow-transition-duration: 300ms;
/* The maximum height of the autocomplete suggestions list. */
--autocomplete-suggestions-height: 15rem;
/* The horizontal gap between the arrow of a dropdown and the label of the current item. */ /* The horizontal gap between the arrow of a dropdown and the label of the current item. */
--dropdown-arrow-gap: 0.25rem; --dropdown-arrow-gap: 0.25rem;
--dropdown-items-height: 15rem; --dropdown-items-height: 15rem;

View File

@ -1,4 +1,9 @@
/** @file Utilities for working with permissions. */ /** @file Utilities for working with permissions. */
import {
type AssetPermission,
compareAssetPermissions,
type User,
} from 'enso-common/src/services/Backend'
import * as permissions from 'enso-common/src/utilities/permissions' import * as permissions from 'enso-common/src/utilities/permissions'
export * from 'enso-common/src/utilities/permissions' export * from 'enso-common/src/utilities/permissions'
@ -17,3 +22,28 @@ export const PERMISSION_CLASS_NAME: Readonly<Record<permissions.Permission, stri
export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs' export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs'
/** CSS classes for the execute permission. */ /** CSS classes for the execute permission. */
export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec' export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec'
/** Try to find a permission belonging to the user. */
export function tryFindSelfPermission(
self: User,
otherPermissions: readonly AssetPermission[] | null,
) {
let selfPermission: AssetPermission | null = null
for (const permission of otherPermissions ?? []) {
// `a >= b` means that `a` does not have more permissions than `b`.
if (selfPermission && compareAssetPermissions(selfPermission, permission) >= 0) {
continue
}
if ('user' in permission && permission.user.userId !== self.userId) {
continue
}
if (
'userGroup' in permission &&
(self.userGroups ?? []).every((groupId) => groupId !== permission.userGroup.id)
) {
continue
}
selfPermission = permission
}
return selfPermission
}

View File

@ -181,7 +181,6 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
'chat-thread-list': 'var(--chat-thread-list-height)', 'chat-thread-list': 'var(--chat-thread-list-height)',
'payment-form': 'var(--payment-form-height)', 'payment-form': 'var(--payment-form-height)',
'paragraph-input': 'var(--paragraph-input-height)', 'paragraph-input': 'var(--paragraph-input-height)',
'autocomplete-suggestions': 'var(--autocomplete-suggestions-height)',
'dropdown-items': 'var(--dropdown-items-height)', 'dropdown-items': 'var(--dropdown-items-height)',
'manage-permissions-modal-permissions-list': 'manage-permissions-modal-permissions-list':
'var(--manage-permissions-modal-permissions-list-height)', 'var(--manage-permissions-modal-permissions-list-height)',

View File

@ -169,6 +169,7 @@ export interface User extends UserInfo {
/** If `false`, this account is awaiting acceptance from an administrator, and endpoints other than /** If `false`, this account is awaiting acceptance from an administrator, and endpoints other than
* `usersMe` will not work. */ * `usersMe` will not work. */
readonly isEnabled: boolean readonly isEnabled: boolean
readonly isOrganizationAdmin: boolean
readonly rootDirectoryId: DirectoryId readonly rootDirectoryId: DirectoryId
readonly profilePicture?: HttpsUrl readonly profilePicture?: HttpsUrl
readonly userGroups: readonly UserGroupId[] | null readonly userGroups: readonly UserGroupId[] | null
@ -1017,8 +1018,8 @@ export interface UpdateOrganizationRequestBody {
/** HTTP request body for the "invite user" endpoint. */ /** HTTP request body for the "invite user" endpoint. */
export interface InviteUserRequestBody { export interface InviteUserRequestBody {
readonly organizationId: OrganizationId
readonly userEmail: EmailAddress readonly userEmail: EmailAddress
readonly resend?: boolean
} }
/** HTTP response body for the "list invitations" endpoint. */ /** HTTP response body for the "list invitations" endpoint. */

View File

@ -578,6 +578,7 @@
"docsColumnName": "Docs", "docsColumnName": "Docs",
"settingsShortcut": "Settings", "settingsShortcut": "Settings",
"closeTabShortcut": "Close Tab",
"openShortcut": "Open", "openShortcut": "Open",
"runShortcut": "Execute as Task", "runShortcut": "Execute as Task",
"closeShortcut": "Close", "closeShortcut": "Close",