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

View File

@ -226,7 +226,7 @@ export default class DrivePageActions extends PageActions {
/** Create a new folder using the icon in the Drive Bar. */
createFolder() {
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,
userGroups: null,
plan: backend.Plan.enterprise,
isOrganizationAdmin: true,
}
const defaultOrganization: backend.OrganizationInfo = {
id: defaultOrganizationId,
@ -259,6 +260,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
isEnabled: true,
userGroups: null,
plan: backend.Plan.enterprise,
isOrganizationAdmin: true,
...rest,
}
users.push(user)
@ -733,6 +735,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
rootDirectoryId,
userGroups: null,
plan: backend.Plan.enterprise,
isOrganizationAdmin: true,
}
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 tooltip?: React.ReactElement | string | false | null
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
readonly tooltipPlacement?: aria.Placement
}
export const TEXT_STYLE = twv.tv({
@ -136,6 +137,7 @@ export const Text = React.forwardRef(function Text(
elementType: ElementType = 'span',
tooltip: tooltipElement = children,
tooltipDisplay = 'whenOverflowing',
tooltipPlacement,
textSelection,
disableLineHeightCompensation = false,
...ariaProps
@ -178,6 +180,7 @@ export const Text = React.forwardRef(function Text(
targetRef: textElementRef,
display: tooltipDisplay,
children: tooltipElement,
...(tooltipPlacement ? { overlayPositionProps: { placement: tooltipPlacement } } : {}),
})
return (

View File

@ -12,7 +12,7 @@ import * as text from '../Text'
// =================
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: {
variant: {
custom: '',

View File

@ -1,10 +1,14 @@
/** @file A select menu with a dropdown. */
import * as React from 'react'
import CloseIcon from '#/assets/cross.svg'
import FocusRing from '#/components/styled/FocusRing'
import Input from '#/components/styled/Input'
import { Button, Text } from '#/components/AriaComponents'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { twMerge } from 'tailwind-merge'
// =================
// === Constants ===
@ -39,7 +43,7 @@ interface InternalBaseAutocompleteProps<T> {
interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
/** Whether selecting multiple values is allowed. */
readonly multiple?: false
readonly setValues: (value: [T]) => void
readonly setValues: (value: readonly [] | readonly [T]) => void
readonly itemsToString?: never
}
@ -76,7 +80,8 @@ export type AutocompleteProps<T> = (
/** A select menu with a dropdown. */
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
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 [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const valuesSet = React.useMemo(() => new Set(values), [values])
@ -181,9 +186,18 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
}
return (
<div onKeyDown={onKeyDown} className="grow">
<div className="relative h-6 w-full">
<div
onKeyDown={onKeyDown}
className={twMerge(
'absolute w-full grow transition-colors',
isDropdownVisible && matchingItems.length !== 0 ?
'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'
: '',
)}
>
<FocusRing within>
<div className="flex flex-1 rounded-full">
<div className="relative z-1 flex flex-1 rounded-full">
{canEditText ?
<Input
type={type}
@ -208,14 +222,13 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
}}
/>
: <div
ref={(element) => element?.focus()}
tabIndex={-1}
className="text grow cursor-pointer whitespace-nowrap bg-transparent px-button-x"
onClick={() => {
setIsDropdownVisible(true)
}}
onBlur={() => {
requestAnimationFrame(() => {
window.setTimeout(() => {
setIsDropdownVisible(false)
})
}}
@ -223,30 +236,33 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
{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>
</FocusRing>
<div className="h">
<div
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',
isDropdownVisible &&
matchingItems.length !== 0 &&
'before:border before:border-primary/10',
)}
>
<div
className={tailwindMerge.twMerge(
'relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default',
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h-0',
'relative z-1 grid h-max w-full rounded-b-xl transition-grid-template-rows duration-200',
isDropdownVisible && matchingItems.length !== 0 ? 'grid-rows-1fr' : 'grid-rows-0fr',
)}
>
<div className="relative max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-b-xl">
{/* FIXME: "Invite" modal does not take into account the height of the autocomplete,
* so the suggestions may go offscreen. */}
{matchingItems.map((item, index) => (
<div
key={itemToKey(item)}
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',
index === selectedIndex && 'bg-black/5',
)}
@ -258,7 +274,9 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
toggleValue(item)
}}
>
<Text truncate="1" className="w-full" tooltipPlacement="left">
{itemToString(item)}
</Text>
</div>
))}
</div>

View File

@ -204,7 +204,7 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
>
<div
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 ?
'before:h-full before:shadow-soft'
: '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 FocusRing from '#/components/styled/FocusRing'
import { useBackendQuery } from '#/hooks/backendHooks'
import * as jsonSchema from '#/utilities/jsonSchema'
import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -38,13 +39,19 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const { value, setValue } = props
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
// but it is more convenient to avoid having plugin infrastructure.
const remoteBackend = backendProvider.useRemoteBackend()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const { getText } = textProvider.useText()
const [autocompleteText, setAutocompleteText] = React.useState(() =>
typeof value === 'string' ? value : 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.
if ('const' in schema) {
@ -57,18 +64,11 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
case 'string': {
if ('format' in schema && schema.format === 'enso-secret') {
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(
<div
className={tailwindMerge.twMerge(
'grow rounded-default border',
isValid ? 'border-primary/10' : 'border-red-700/60',
'w-60 rounded-default border-0.5',
isValid ? 'border-primary/20' : 'border-red-700/60',
)}
>
<Autocomplete
@ -79,7 +79,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
values={isValid ? [value] : []}
setValues={(values) => {
setValue(values[0])
setValue(values[0] ?? '')
}}
text={autocompleteText}
setText={setAutocompleteText}
@ -97,8 +97,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'string' ? value : ''}
size={1}
className={tailwindMerge.twMerge(
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
'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/20' : 'border-red-700/60',
)}
placeholder={getText('enterText')}
onChange={(event) => {
@ -125,8 +125,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''}
size={1}
className={tailwindMerge.twMerge(
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
'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/20' : 'border-red-700/60',
)}
placeholder={getText('enterNumber')}
onChange={(event) => {
@ -154,8 +154,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''}
size={1}
className={tailwindMerge.twMerge(
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
'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/20' : 'border-red-700/60',
)}
placeholder={getText('enterInteger')}
onChange={(event) => {
@ -195,19 +195,13 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
)
if (jsonSchema.constantValue(defs, schema).length !== 1) {
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) => {
const { key, schema: childSchema } = definition
const isOptional = !requiredProperties.includes(key)
return jsonSchema.constantValue(defs, childSchema).length === 1 ?
null
: <div
key={key}
className="flex flex-wrap items-center gap-2"
{...('description' in childSchema ?
{ title: String(childSchema.description) }
: {})}
>
: <>
<FocusArea active={isOptional} direction="horizontal">
{(innerProps) => {
const isPresent = value != null && key in value
@ -218,7 +212,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
isDisabled={!isOptional}
isActive={!isOptional || isPresent}
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',
)}
onPress={() => {
@ -254,6 +248,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
}}
</FocusArea>
{value != null && key in value && (
<div className="col-start-2">
<JSONSchemaInput
readOnly={readOnly}
defs={defs}
@ -291,8 +286,9 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
})
}}
/>
)}
</div>
)}
</>
})}
</div>,
)

View File

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

View File

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

View File

@ -37,6 +37,7 @@ import * as backendModule from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
@ -49,6 +50,7 @@ import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import { useQuery } from '@tanstack/react-query'
// =================
// === Constants ===
@ -154,6 +156,14 @@ export default function AssetRow(props: AssetRowProps) {
const openProjectMutate = openProjectMutation.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 { selectedKeys } = driveStore.getState()
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
@ -798,7 +808,12 @@ export default function AssetRow(props: AssetRowProps) {
}
}}
onDragStart={(event) => {
if (rowState.isEditingName) {
if (
rowState.isEditingName ||
(projectState !== backendModule.ProjectState.closed &&
projectState !== backendModule.ProjectState.created &&
projectState != null)
) {
event.preventDefault()
} else {
props.onDragStart?.(event)

View File

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

View File

@ -18,7 +18,7 @@ export default function Separator(props: SeparatorProps) {
return (
!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 Button from '#/components/styled/Button'
import FocusRing from '#/components/styled/FocusRing'
import { twMerge } from '#/utilities/tailwindMerge'
// =====================
// === SettingsInput ===
@ -61,8 +62,10 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
{
ref,
className:
'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',
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 px-1 border-0.5 border-transparent',
!isDisabled && 'border-primary/20',
),
...(type == null ? {} : { type: isShowingPassword ? 'text' : type }),
disabled: isDisabled,
size: 1,

View File

@ -10,6 +10,7 @@ import ArrowLeftIcon from '#/assets/arrow_left.svg'
import ArrowRightIcon from '#/assets/arrow_right.svg'
import CameraIcon from '#/assets/camera.svg'
import CloseIcon from '#/assets/close.svg'
import CloseTabIcon from '#/assets/close_tab.svg'
import CloudToIcon from '#/assets/cloud_to.svg'
import CopyIcon from '#/assets/copy.svg'
import CopyAsPathIcon from '#/assets/copy_as_path.svg'
@ -52,6 +53,8 @@ export function createBindings() {
export const BINDINGS = inputBindings.defineBindings({
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 },
run: { name: 'Execute as Task', bindings: ['Shift+Enter'], icon: Play2Icon },
close: { name: 'Close', bindings: [], icon: CloseIcon },

View File

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

View File

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

View File

@ -144,11 +144,11 @@ export function useOpenProjectMutation() {
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
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 } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onSuccess: (_, { 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
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
: asset.type === backendModule.AssetType.project ?
localBackend?.getProjectDirectoryPath(asset.id) ?? null
localBackend?.getProjectPath(asset.id) ?? null
: localBackendModule.extractTypeAndId(asset.id).id
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })

View File

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

View File

@ -310,7 +310,13 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
>
<div className="h-[32px]" />
{areSuggestionsVisible && (
<div
className={tailwindMerge.twMerge(
'grid transition-grid-template-rows duration-200',
areSuggestionsVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
)}
>
<div className="overflow-y-auto overflow-x-hidden">
<div className="relative mt-3 flex flex-col gap-3">
{/* Tags (`name:`, `modified:`, etc.) */}
<Tags
@ -405,7 +411,8 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
))}
</div>
</div>
)}
</div>
</div>
</div>
<SvgMask
src={FindIcon}

View File

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

View File

@ -97,27 +97,21 @@ export default function Editor(props: EditorProps) {
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
})
if (isOpeningFailed) {
// eslint-disable-next-line no-restricted-syntax
return (
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
const shouldRefetch = !projectQuery.isError && !projectQuery.isLoading
if (!isOpeningFailed && !isOpening && isProjectClosed && shouldRefetch) {
startProject(project)
}
return isOpeningFailed ?
<errorBoundary.ErrorDisplay
error={openingError}
resetErrorBoundary={() => {
startProject(project)
}}
/>
)
}
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
const shouldRefetch = !(projectQuery.isError || projectQuery.isLoading)
if (!isOpening && isProjectClosed && shouldRefetch) {
startProject(project)
}
return (
<div
: <div
className={twMerge.twJoin('contents', hidden && 'hidden')}
data-testvalue={project.id}
data-testid="editor"
@ -150,7 +144,6 @@ export default function Editor(props: EditorProps) {
}
})()}
</div>
)
}
// ======================

View File

@ -21,6 +21,8 @@ import NewLabelModal from '#/modals/NewLabelModal'
import type Backend from '#/services/Backend'
import AssetEventType from '#/events/AssetEventType'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import * as array from '#/utilities/array'
import type AssetQuery from '#/utilities/AssetQuery'
import * as drag from '#/utilities/drag'
@ -44,9 +46,14 @@ export default function Labels(props: LabelsProps) {
const currentNegativeLabels = query.negativeLabels
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetEvent = useDispatchAssetEvent()
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 (
<FocusArea direction="vertical">

View File

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

View File

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

View File

@ -27,9 +27,9 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
export interface MembersTableProps {
readonly backend: Backend
/** If `true`, initialize the users list with self to avoid needing a loading spinner. */
readonly populateWithSelf?: true
readonly draggable?: true
readonly allowDelete?: true
readonly populateWithSelf?: boolean
readonly draggable?: boolean
readonly allowDelete?: boolean
}
/** 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 { getText } = textProvider.useText()
const [errorMessage, setErrorMessage] = React.useState('')
const isSubmitting = React.useRef(false)
const value = getValue(context)
const isEditable = getEditable(context)
@ -42,6 +43,9 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
key={value}
type="text"
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()
}}
/>
@ -52,6 +56,8 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
validationErrors={{ [FIELD_NAME]: errorMessage }}
onSubmit={async (event) => {
event.preventDefault()
if (!isSubmitting.current) {
isSubmitting.current = true
const [[, newValue] = []] = new FormData(event.currentTarget)
if (typeof newValue === 'string') {
setErrorMessage('')
@ -61,6 +67,8 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
setErrorMessage(errorModule.getMessageOrToString(error))
}
}
isSubmitting.current = false
}
}}
>
<aria.TextField

View File

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

View File

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

View File

@ -54,6 +54,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
[users],
)
const isLoading = userGroups == null || users == null
const isAdmin = user.isOrganizationAdmin
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
@ -65,6 +66,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
const { dragAndDropHooks } = aria.useDragAndDrop({
isDisabled: !isAdmin,
getDropOperation: (target, types, allowedOperations) =>
(
allowedOperations.includes('copy') &&
@ -132,6 +134,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
return (
<>
{isAdmin && (
<ariaComponents.ButtonGroup verticalAlign="center">
{shouldDisplayPaywall && (
<paywallComponents.PaywallDialogButton
@ -167,6 +170,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
</span>
)}
</ariaComponents.ButtonGroup>
)}
<div
ref={rootRef}
className={tailwindMerge.twMerge(

View File

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

View File

@ -166,7 +166,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
}
},
validate: (name) => (/\S/.test(name) ? true : ''),
getEditable: () => true,
getEditable: (context) => context.user.isOrganizationAdmin,
},
{
type: SettingsEntryType.input,
@ -183,7 +183,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
isEmail(email) ? true
: email === '' ? ''
: context.getText('invalidEmailValidationError'),
getEditable: () => true,
getEditable: (context) => context.user.isOrganizationAdmin,
},
{
type: SettingsEntryType.input,
@ -196,7 +196,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
await context.updateOrganization([{ website: newWebsite }])
}
},
getEditable: () => true,
getEditable: (context) => context.user.isOrganizationAdmin,
},
{
type: SettingsEntryType.input,
@ -208,7 +208,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
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,
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 { useInputBindings } from '#/providers/InputBindingsProvider'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
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 rightSegments = [
'M 0 0',
`L ${rootBounds.width} 0`,
`L ${rootBounds.width} ${rootBounds.height}`,
`L ${rootBounds.width + window.outerWidth} 0`,
`L ${rootBounds.width + window.outerWidth} ${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}`,
]
@ -194,10 +196,11 @@ interface InternalTabProps extends Readonly<React.PropsWithChildren> {
export function Tab(props: InternalTabProps) {
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
const { onLoadEnd } = props
const { getText } = textProvider.useText()
const inputBindings = useInputBindings()
const { setSelectedTab } = useTabBarContext()
const ref = React.useRef<HTMLDivElement | null>(null)
const isLoadingRef = React.useRef(true)
const { getText } = textProvider.useText()
const actuallyActive = isActive && !isHidden
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(() => {
if (actuallyActive && ref.current) {
setSelectedTab(ref.current)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -361,8 +361,10 @@ export default class LocalBackend extends Backend {
name: projectManager.ProjectName(body.projectName),
})
}
const parentId = this.projectManager.getProjectDirectoryPath(id)
const result = await this.projectManager.listDirectory(parentId)
const parentPath = projectManager.getDirectoryAndName(
this.projectManager.getProjectPath(id),
).directoryPath
const result = await this.projectManager.listDirectory(parentPath)
const project = result.flatMap((listedProject) =>
(
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
@ -588,7 +590,7 @@ export default class LocalBackend extends Backend {
const from =
typeAndId.type !== backend.AssetType.project ?
typeAndId.id
: this.projectManager.getProjectDirectoryPath(typeAndId.id)
: this.projectManager.getProjectPath(typeAndId.id)
const fileName = fileInfo.fileName(from)
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
await this.projectManager.moveFile(from, to)
@ -629,9 +631,9 @@ export default class LocalBackend extends Backend {
await this.projectManager.moveFile(from, to)
}
/** Construct a new path using the given parent directory and a file name. */
getProjectDirectoryPath(id: backend.ProjectId) {
return this.projectManager.getProjectDirectoryPath(extractTypeAndId(id).id)
/** Get the path of a project. */
getProjectPath(id: backend.ProjectId) {
return this.projectManager.getProjectPath(extractTypeAndId(id).id)
}
/** 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 dateTime from '#/utilities/dateTime'
import * as newtype from '#/utilities/newtype'
import invariant from 'tiny-invariant'
// =================
// === Constants ===
@ -389,6 +390,13 @@ export default class ProjectManager {
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. */
getProjectDirectoryPath(projectId: UUID) {
const projectPath = this.internalProjectPaths.get(projectId)

View File

@ -270,15 +270,7 @@ export default class RemoteBackend extends Backend {
/** Resend an invitation to a user. */
override async resendInvitation(userEmail: backend.EmailAddress): Promise<void> {
const response = await this.post(remoteBackendPaths.INVITATION_PATH, {
userEmail,
resend: true,
})
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'resendInvitationBackendError')
} else {
return
}
await this.inviteUser({ userEmail, resend: true })
}
/** 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'
/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */
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'
/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */
export const CREATE_PERMISSION_PATH = 'permissions'

View File

@ -117,8 +117,6 @@
--label-icons-gap: 0.25rem;
/* The transition duration of an arrow transitioning between two directions. */
--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. */
--dropdown-arrow-gap: 0.25rem;
--dropdown-items-height: 15rem;

View File

@ -1,4 +1,9 @@
/** @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'
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'
/** CSS classes for the execute permission. */
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)',
'payment-form': 'var(--payment-form-height)',
'paragraph-input': 'var(--paragraph-input-height)',
'autocomplete-suggestions': 'var(--autocomplete-suggestions-height)',
'dropdown-items': 'var(--dropdown-items-height)',
'manage-permissions-modal-permissions-list':
'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
* `usersMe` will not work. */
readonly isEnabled: boolean
readonly isOrganizationAdmin: boolean
readonly rootDirectoryId: DirectoryId
readonly profilePicture?: HttpsUrl
readonly userGroups: readonly UserGroupId[] | null
@ -1017,8 +1018,8 @@ export interface UpdateOrganizationRequestBody {
/** HTTP request body for the "invite user" endpoint. */
export interface InviteUserRequestBody {
readonly organizationId: OrganizationId
readonly userEmail: EmailAddress
readonly resend?: boolean
}
/** HTTP response body for the "list invitations" endpoint. */

View File

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