Dashboard improvements (from 24 September 2024 + 30 September 2024) (#11219)

- Close https://github.com/enso-org/cloud-v2/issues/1508
- ⚠️ Labels modal - add selection indicator where user can (de)select multiple labels
- Checkboxes currently still cause the dialog to (incorrectly) close
- Edit datalink -> select enso secret -> options are too narrow for enso path. Strip enso://Users(Teams), if possible make the options list longer on the left side
- Added (or rather, re-enabled via CSS) horizontal scroll instead
- Make Versions, Sessions, Settings as tabs similar to documentation panel in graph editor
- Edit description in context menu should open asset panel with description input active
- Edit secret should be moved to asset panel (same like datalink)
- Dim background when "edit description", "edit secret", or "edit datalink" are clicked/triggered via shortcut to highlight input
- Hide unused (= no backend support) columns and icons: accessed by projects and accessed data
- *Partial* frontend fixes for https://github.com/enso-org/cloud-v2/issues/1529
- (1) Fix settings title being horizontally centered and split on multiple lines
- (2)  backend issue
- (3)  out of scope
- (4)  backend issue
- (5)  out of scope
- (6)  out of scope
- (7)  backend issue
- (8)  already fixed in #11126
- (9)  out of scope (potentially requires a way to trigger a tooltip on a disabled button)
- (10)  out of scope
- (11)  out of scope
- (12)  out of scope
- (13) URL encode `enso://` URLs in "copy as path"
- (14) Double click on datalink make asset open and close (not sure if this has already been fixed)
- (15) Clicking anywhere on Asset Panel no longer deselects assets (not sure if this has already been fixed)
- (16)  addressed in #11268
- (17) Make list of labels in Asset Panel (right sidebar) horizontal instead of (incorrectly) vertical
- (18) Only show "Billing" settings tab for organization admins
- Use "workspace" instead of "network" icon for project tabs
- Other fixes:
- Fix Asset Panel (right sidebar) not being able to be toggled off if it is temporarily open (when triggered from editing description, or editing secret, or editing datalink)
- Make "cancel" and "reset" buttons default to outline variant, instead of ghost variant
- Fix style of dropdown
- Change Datalink editor dialog so that object keys are above inputs, not beside them. This gives inputs much more horizontal space for children of deeply nested objects.

Issues left to fix:
- Checkboxes currently still cause the dialog to (incorrectly) close
- "Edit description" actions etc. do not properly focus inputs

Issues left to do (out of scope):
- Show username of user currently using a project (possibly as tooltip?) if the project is currently disabled.
- Dropdown and autocomplete entries should be in their own dialog, so that they can escape the parent dialog if they are too long

# Important Notes
None
This commit is contained in:
somebody1234 2024-10-09 19:10:49 +10:00 committed by GitHub
parent 80317dc950
commit 7a00e6ef26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 1513 additions and 913 deletions

View File

@ -8,8 +8,6 @@ const PASS_TIMEOUT = 5_000
test.test('extra columns should stick to right side of assets table', ({ page }) =>
actions
.mockAllAndLogin({ page })
.driveTable.toggleColumn.accessedByProjects()
.driveTable.toggleColumn.accessedData()
.withAssetsTable(async (table) => {
await table.evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element
@ -51,13 +49,11 @@ test.test('extra columns should stick to top of scroll container', async ({ page
},
})
await actions.locateAccessedByProjectsColumnToggle(page).click()
await actions.locateAccessedDataColumnToggle(page).click()
await actions.locateAssetsTable(page).evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element
while (
scrollableParent != null &&
scrollableParent.scrollWidth <= scrollableParent.clientWidth
scrollableParent.scrollHeight <= scrollableParent.clientHeight
) {
scrollableParent = scrollableParent.parentElement
}
@ -75,7 +71,7 @@ test.test('extra columns should stick to top of scroll container', async ({ page
let scrollableParent: HTMLElement | SVGElement | null = element
while (
scrollableParent != null &&
scrollableParent.scrollWidth <= scrollableParent.clientWidth
scrollableParent.scrollHeight <= scrollableParent.clientHeight
) {
scrollableParent = scrollableParent.parentElement
}

View File

@ -38,6 +38,7 @@
<script type="module" src="./src/entrypoint.ts" defer></script>
</head>
<body>
<div id="enso-spotlight" class="enso-spotlight"></div>
<div id="enso-dashboard" class="enso-dashboard"></div>
<div id="enso-chat" class="enso-chat"></div>
<div id="enso-portal-root" class="enso-portal-root"></div>

View File

@ -29,22 +29,25 @@
},
"dependencies": {
"@aws-amplify/auth": "5.6.5",
"amazon-cognito-identity-js": "6.3.6",
"@aws-amplify/core": "5.8.5",
"@hookform/resolvers": "^3.4.0",
"@internationalized/date": "^3.5.5",
"@monaco-editor/react": "4.6.0",
"@react-aria/interactions": "^3.22.3",
"@sentry/react": "^7.74.0",
"@stripe/react-stripe-js": "^2.7.1",
"@stripe/stripe-js": "^3.5.0",
"@tanstack/react-query": "5.55.0",
"@tanstack/vue-query": ">= 5.54.0 < 5.56.0",
"ajv": "^8.12.0",
"amazon-cognito-identity-js": "6.3.6",
"clsx": "^2.1.1",
"enso-common": "workspace:*",
"framer-motion": "11.3.0",
"input-otp": "1.2.4",
"is-network-error": "^1.0.1",
"monaco-editor": "0.48.0",
"qrcode.react": "3.1.0",
"react": "^18.3.1",
"react-aria": "^3.34.3",
"react-aria-components": "^1.3.3",
@ -61,9 +64,7 @@
"ts-results": "^3.3.0",
"validator": "^13.12.0",
"zod": "^3.23.8",
"zustand": "^4.5.4",
"input-otp": "1.2.4",
"qrcode.react": "3.1.0"
"zustand": "^4.5.4"
},
"devDependencies": {
"@fast-check/vitest": "^0.0.8",

View File

@ -144,8 +144,8 @@ export const Checkbox = forwardRef(function Checkbox<
const { store, removeSelected, addSelected } = useCheckboxContext()
// eslint-disable-next-line react-hooks/rules-of-hooks,no-restricted-syntax
const formInstance = form ?? Form.useFormContext()
// eslint-disable-next-line react-hooks/rules-of-hooks, no-restricted-syntax
const formInstance = (form ?? Form.useFormContext()) as unknown as FormInstance<Schema>
const { isSelected, field, onChange, name } = useStore(store, (state) => {
const { insideGroup } = state
@ -153,7 +153,7 @@ export const Checkbox = forwardRef(function Checkbox<
if (insideGroup) {
const value = props.value
invariant(value != null, 'Checkbox must have a value when placed inside a group')
invariant(value != null, '`Checkbox` must have a value when placed inside a group')
return {
isSelected: state.selected.has(value),
@ -172,21 +172,17 @@ export const Checkbox = forwardRef(function Checkbox<
},
}
} else {
invariant(props.name != null, 'Checkbox must have a name when outside a group')
invariant(props.name != null, '`Checkbox` must have a name when outside a group')
// eslint-disable-next-line no-restricted-syntax
const formInstanceTyped = formInstance as unknown as FormInstance<Schema>
const fieldInstance = formInstanceTyped.register(props.name)
const fieldInstance = formInstance.register(props.name)
return {
field: fieldInstance,
name: props.name,
isSelected: props.isSelected ?? false,
onChange: (checked: boolean) => {
void fieldInstance
.onChange({ target: { value: checked } })
.then(() => formInstanceTyped.trigger(props.name))
onChange: async (checked: boolean) => {
await fieldInstance.onChange({ target: { value: checked } })
await formInstance.trigger(props.name)
},
}
}

View File

@ -79,6 +79,9 @@ export const Form = forwardRef(function Form<
)
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
React.useImperativeHandle(form?.closeRef, () => dialogContext?.close ?? (() => {}), [
dialogContext?.close,
])
const base = styles.FORM_STYLES({
className: typeof className === 'function' ? className(innerForm) : className,

View File

@ -24,6 +24,8 @@ export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'>
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any>
/** Defaults to `reset`. */
readonly action?: 'cancel' | 'reset'
}
/**
@ -32,10 +34,11 @@ export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'>
export function Reset(props: ResetProps): React.JSX.Element {
const { getText } = useText()
const {
variant = 'ghost-fading',
variant = 'outline',
size = 'medium',
testId = 'form-reset-button',
children = getText('reset'),
action = 'reset',
children = action === 'cancel' ? getText('cancel') : getText('reset'),
...buttonProps
} = props

View File

@ -4,20 +4,18 @@
* Submit button for forms.
* Manages the form state and displays a loading spinner when the form is submitting.
*/
import * as React from 'react'
import type { JSX } from 'react'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import * as formContext from './FormProvider'
import type * as types from './types'
import { Button, useDialogContext, type ButtonProps } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import { useFormContext } from './FormProvider'
import type { FormInstance } from './types'
/**
* Additional props for the Submit component.
*/
interface SubmitButtonBaseProps {
readonly variant?: ariaComponents.ButtonProps['variant']
readonly variant?: ButtonProps['variant']
/**
* Connects the submit button to a form.
* If not provided, the button will use the nearest form context.
@ -26,20 +24,15 @@ interface SubmitButtonBaseProps {
*/
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any>
/**
* Prop that allows to close the parent dialog without submitting the form.
*
* This looks tricky, but it's recommended by MDN as a receipt for closing the dialog without submitting the form.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#closing_a_dialog_with_a_required_form_input
*/
readonly formnovalidate?: boolean
readonly form?: FormInstance<any>
/** Defaults to `submit`. */
readonly action?: 'cancel' | 'submit' | 'update'
}
/**
* Props for the Submit component.
*/
export type SubmitProps = Omit<ariaComponents.ButtonProps, 'href' | 'variant'> &
export type SubmitProps = Omit<ButtonProps, 'formnovalidate' | 'href' | 'variant'> &
SubmitButtonBaseProps
/**
@ -47,28 +40,30 @@ export type SubmitProps = Omit<ariaComponents.ButtonProps, 'href' | 'variant'> &
*
* Manages the form state and displays a loading spinner when the form is submitting.
*/
export function Submit(props: SubmitProps): React.JSX.Element {
const { getText } = textProvider.useText()
export function Submit(props: SubmitProps): JSX.Element {
const { getText } = useText()
const {
size = 'medium',
formnovalidate = false,
action = 'submit',
loading = false,
children = formnovalidate ? getText('cancel') : getText('submit'),
variant = formnovalidate ? 'ghost-fading' : 'submit',
testId = formnovalidate ? 'form-cancel-button' : 'form-submit-button',
children = action === 'cancel' ? getText('cancel')
: action === 'update' ? getText('update')
: getText('submit'),
variant = action === 'cancel' ? 'outline' : 'submit',
testId = action === 'cancel' ? 'form-cancel-button' : 'form-submit-button',
...buttonProps
} = props
const dialogContext = ariaComponents.useDialogContext()
const form = formContext.useFormContext(props.form)
const dialogContext = useDialogContext()
const form = useFormContext(props.form)
const { formState } = form
const isLoading = formnovalidate ? false : loading || formState.isSubmitting
const type = formnovalidate || isLoading ? 'button' : 'submit'
const isLoading = action === 'cancel' ? false : loading || formState.isSubmitting
const type = action === 'cancel' || isLoading ? 'button' : 'submit'
return (
<ariaComponents.Button
<Button
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
@ -78,12 +73,12 @@ export function Submit(props: SubmitProps): React.JSX.Element {
loading={isLoading}
testId={testId}
onPress={() => {
if (formnovalidate) {
if (action === 'cancel') {
dialogContext?.close()
}
}}
>
{children}
</ariaComponents.Button>
</Button>
)
}

View File

@ -87,6 +87,8 @@ export interface UseFormProps<Schema extends TSchema, SubmitResult = void>
* Debug name for the form. Use it to identify the form in the tanstack query devtools.
*/
readonly debugName?: string
// eslint-disable-next-line @typescript-eslint/ban-types
readonly method?: 'dialog' | (string & {}) | undefined
}
/**
@ -131,6 +133,7 @@ export interface UseFormReturn<Schema extends TSchema>
readonly submit: (event?: FormEvent<HTMLFormElement> | null | undefined) => Promise<void>
readonly schema: Schema
readonly setFormError: (error: string) => void
readonly closeRef: React.MutableRefObject<() => void>
}
/**

View File

@ -49,6 +49,7 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
): types.UseFormReturn<Schema> {
const { getText } = useText()
const [initialTypePassed] = React.useState(() => getArgsType(optionsOrFormInstance))
const closeRef = React.useRef(() => {})
const argsType = getArgsType(optionsOrFormInstance)
@ -71,6 +72,7 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
onSubmitted,
onSubmitSuccess,
debugName,
method,
...options
} = optionsOrFormInstance
@ -149,7 +151,13 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
// This is safe, because we transparently passing the result of the onSubmit function,
// and the type of the result is the same as the type of the SubmitResult.
// eslint-disable-next-line no-restricted-syntax
return (await onSubmit?.(fieldValues, form)) as SubmitResult
const result = (await onSubmit?.(fieldValues, form)) as SubmitResult
if (method === 'dialog') {
closeRef.current()
}
return result
} catch (error) {
const isJSError = errorUtils.isJSError(error)
@ -226,6 +234,7 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
schema: computedSchema,
setFormError,
handleSubmit: formInstance.handleSubmit,
closeRef,
}
return form

View File

@ -0,0 +1,196 @@
/** @file A combo box with a list of items that can be filtered. */
import { useContext, useMemo, type ForwardedRef } from 'react'
import CrossIcon from '#/assets/cross.svg'
import ArrowIcon from '#/assets/folder_arrow.svg'
import {
ComboBox as AriaComboBox,
ComboBoxStateContext,
Label,
ListBox,
ListBoxItem,
type ComboBoxProps as AriaComboBoxProps,
} from '#/components/aria'
import {
Button,
Form,
Input,
Popover,
Text,
type FieldComponentProps,
type FieldPath,
type FieldProps,
type FieldStateProps,
type FieldValues,
type InputProps,
type TSchema,
} from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import { forwardRef } from '#/utilities/react'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
const POPOVER_CROSS_OFFSET_PX = -32
const COMBO_BOX_STYLES = tv({
base: 'w-full',
slots: {
inputContainer: 'flex items-center gap-2 px-1.5',
input: 'grow',
resetButton: '',
popover: 'py-2',
listBox: 'text-primary text-xs',
listBoxItem: 'min-w-min cursor-pointer rounded-full hover:bg-hover-bg px-2',
},
defaultVariants: {
size: 'medium',
},
})
/** Props for a {@link ComboBox}. */
export interface ComboBoxProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends FieldStateProps<
Omit<
AriaComboBoxProps<FieldValues<Schema>[TFieldName]>,
'children' | 'className' | 'style'
> & { value?: FieldValues<Schema>[TFieldName] },
Schema,
TFieldName
>,
FieldProps,
Pick<FieldComponentProps<Schema>, 'className' | 'style'>,
VariantProps<typeof COMBO_BOX_STYLES>,
Pick<InputProps<Schema, TFieldName>, 'placeholder'> {
/** This may change as the user types in the input. */
readonly items: readonly FieldValues<Schema>[TFieldName][]
readonly children: (item: FieldValues<Schema>[TFieldName]) => string
readonly noResetButton?: boolean
}
/** A combo box with a list of items that can be filtered. */
export const ComboBox = forwardRef(function ComboBox<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(props: ComboBoxProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
const {
name,
items,
isDisabled,
form,
defaultValue,
label,
isRequired,
className,
placeholder,
children,
noResetButton = false,
variants = COMBO_BOX_STYLES,
} = props
const itemsAreStrings = typeof items[0] === 'string'
const effectiveItems = useMemo(
() => (itemsAreStrings ? items.map((id) => ({ id })) : items),
[items, itemsAreStrings],
)
const { fieldState, formInstance } = Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const styles = variants({})
return (
<Form.Field
form={formInstance}
name={name}
fullWidth
label={label}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']}
isRequired={isRequired}
isInvalid={fieldState.invalid}
aria-details={props['aria-details']}
ref={ref}
style={props.style}
>
<Form.Controller
control={formInstance.control}
name={name}
render={(renderProps) => {
return (
<AriaComboBox
className={styles.base({ className })}
// @ts-expect-error Items must not be strings; this is a limitation of `react-aria`.
items={effectiveItems}
{...renderProps.field}
onSelectionChange={(key) => {
renderProps.field.onChange(key ?? '')
}}
>
<Label>{label}</Label>
<div className={styles.inputContainer()}>
<Button variant="icon" icon={ArrowIcon} className="rotate-90" />
<Input
name={name}
placeholder={placeholder}
size="custom"
variant="custom"
value={renderProps.field.value}
/>
{!noResetButton && <ComboBoxResetButton className={styles.resetButton()} />}
</div>
<Popover crossOffset={POPOVER_CROSS_OFFSET_PX} className={styles.popover()}>
<ListBox className={styles.listBox()}>
{(item) => {
const text = children(
// @ts-expect-error When items are strings, they are mapped to
// `{ id: item }`.
// eslint-disable-next-line no-restricted-syntax
(itemsAreStrings ? item.id : item) as FieldValues<Schema>[TFieldName],
)
return (
<ListBoxItem id={text} className={styles.listBoxItem()}>
<Text truncate="1" className="w-full" tooltipPlacement="left">
{text}
</Text>
</ListBoxItem>
)
}}
</ListBox>
</Popover>
</AriaComboBox>
)
}}
/>
</Form.Field>
)
})
/** Props for a {@link ComboBoxResetButton}. */
interface ComboBoxResetButtonProps {
readonly className?: string
}
/** A reset button for a {@link ComboBox}. */
function ComboBoxResetButton(props: ComboBoxResetButtonProps) {
const { className } = props
const state = useContext(ComboBoxStateContext)
const { getText } = useText()
return (
<Button
// Do not inherit default `Button` behavior from `ComboBox`.
slot={null}
variant="icon"
aria-label={getText('reset')}
icon={CrossIcon}
className={className ?? ''}
onPress={() => {
state.setInputValue('')
}}
/>
)
}

View File

@ -0,0 +1,2 @@
/** @file Barrel file for the `ComboBox` component. */
export * from './ComboBox'

View File

@ -1,2 +1,2 @@
/** @file Barrel file for the DatePicker component. */
/** @file Barrel file for the `DatePicker` component. */
export * from './DatePicker'

View File

@ -16,7 +16,6 @@ import { useSyncRef } from '#/hooks/syncRefHooks'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants'
import { DIALOG_BACKGROUND } from '../../Dialog'
const DROPDOWN_STYLES = tv({
base: 'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy',
@ -48,12 +47,10 @@ const DROPDOWN_STYLES = tv({
slots: {
container: 'absolute left-0 h-full w-full min-w-max',
options:
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors',
'relative backdrop-blur-md before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors',
optionsSpacing: 'padding relative h-6',
optionsContainer: DIALOG_BACKGROUND({
className:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
}),
optionsContainer:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
optionsList: 'overflow-hidden',
optionsItem:
'flex h-6 items-center gap-dropdown-arrow rounded-input px-input-x transition-colors focus:cursor-default focus:bg-frame focus:font-bold focus:focus-ring not-focus:hover:bg-hover-bg not-selected:hover:bg-hover-bg',

View File

@ -46,7 +46,7 @@ export interface InputProps<Schema extends TSchema, TFieldName extends FieldPath
readonly inputRef?: Ref<HTMLInputElement>
readonly addonStart?: ReactNode
readonly addonEnd?: ReactNode
readonly placeholder?: string
readonly placeholder?: string | undefined
/** The icon to display in the input. */
readonly icon?: ReactElement | string | null
readonly variants?: ExtractFunction<typeof INPUT_STYLES> | undefined

View File

@ -4,6 +4,7 @@
* Barrel export file for Inputs
*/
export * from './ComboBox'
export * from './DatePicker'
export * from './Dropdown'
export * from './Input'

View File

@ -191,7 +191,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
)}
>
<FocusRing within>
<div className="relative z-1 flex flex-1 rounded-full">
<div className="relative z-1 flex flex-1 items-center gap-2 rounded-full px-2">
{canEditText ?
<Input
name="autocomplete"
@ -199,10 +199,10 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
ref={inputRef}
autoFocus={autoFocus}
size="custom"
variant="custom"
value={text ?? ''}
autoComplete="off"
{...(placeholder == null ? {} : { placeholder })}
className="text grow rounded-full bg-transparent px-button-x"
onFocus={() => {
setIsDropdownVisible(true)
}}
@ -216,9 +216,10 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
}}
/>
: <div
: <Text
tabIndex={-1}
className="text w-full grow cursor-pointer overflow-auto whitespace-nowrap bg-transparent px-button-x scroll-hidden"
truncate="1"
tooltipPlacement="left"
onClick={() => {
setIsDropdownVisible(true)
}}
@ -229,13 +230,12 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
}}
>
{itemsToString?.(values) ?? (values[0] != null ? children(values[0]) : ZWSP)}
</div>
</Text>
}
<Button
size="medium"
variant="icon"
icon={CloseIcon}
className="absolute right-1 top-1/2 -translate-y-1/2"
onPress={() => {
setValues([])
// setIsDropdownVisible(true)
@ -250,7 +250,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
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">
<div className="relative max-h-60 w-full overflow-auto 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) => (

View File

@ -50,8 +50,9 @@ function ColorPickerItem(props: InternalColorPickerItemProps) {
// ===================
/** Props for a {@link ColorPicker}. */
export interface ColorPickerProps extends Readonly<aria.RadioGroupProps> {
export interface ColorPickerProps extends Readonly<Omit<aria.RadioGroupProps, 'className'>> {
readonly children?: React.ReactNode
readonly className?: string
readonly pickerClassName?: string
readonly setColor: (color: backend.LChColor) => void
}
@ -61,13 +62,13 @@ export default forwardRef(ColorPicker)
/** A color picker to select from a predetermined list of colors. */
function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { pickerClassName = '', children, setColor, ...radioGroupProps } = props
const { className, pickerClassName = '', children, setColor, ...radioGroupProps } = props
return (
<RadioGroup
ref={ref}
{...radioGroupProps}
orientation="horizontal"
className="flex flex-col"
className={tailwindMerge.twMerge('flex flex-col', className)}
onChange={(value) => {
const color = backend.COLOR_STRING_TO_COLOR.get(value)
if (color != null) {
@ -76,7 +77,12 @@ function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivEle
}}
>
{children}
<div className={tailwindMerge.twMerge('flex items-center gap-colors', pickerClassName)}>
<div
className={tailwindMerge.twMerge(
'flex items-center justify-between gap-colors',
pickerClassName,
)}
>
{backend.COLORS.map((currentColor, i) => (
<ColorPickerItem key={i} color={currentColor} />
))}

View File

@ -1,8 +1,8 @@
/** @file A dynamic wizard for creating an arbitrary type of Datalink. */
import { Fragment, type JSX, useState } from 'react'
import { Checkbox, Input, Text } from '#/components/aria'
import { Button, Dropdown } from '#/components/AriaComponents'
import { Input } from '#/components/aria'
import { Button, Checkbox, Dropdown, Text } from '#/components/AriaComponents'
import Autocomplete from '#/components/Autocomplete'
import FocusRing from '#/components/styled/FocusRing'
import { useBackendQuery } from '#/hooks/backendHooks'
@ -11,6 +11,7 @@ import { useText } from '#/providers/TextProvider'
import { constantValueOfSchema, getSchemaName, lookupDef } from '#/utilities/jsonSchema'
import { asObject, singletonObjectOrNull } from '#/utilities/object'
import { twMerge } from '#/utilities/tailwindMerge'
import { twJoin } from 'tailwind-merge'
// =======================
// === JSONSchemaInput ===
@ -24,6 +25,8 @@ export interface JSONSchemaInputProps {
readonly schema: object
readonly path: string
readonly getValidator: (path: string) => (value: unknown) => boolean
readonly noBorder?: boolean
readonly isAbsent?: boolean
readonly value: NonNullable<unknown> | null
readonly onChange: (value: NonNullable<unknown> | null) => void
}
@ -31,7 +34,7 @@ export interface JSONSchemaInputProps {
/** A dynamic wizard for creating an arbitrary type of Datalink. */
export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const { dropdownTitle, readOnly = false, defs, schema, path, getValidator } = props
const { value, onChange } = props
const { noBorder = false, isAbsent = false, value, onChange } = 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 = useRemoteBackendStrict()
@ -39,7 +42,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const [autocompleteText, setAutocompleteText] = useState(() =>
typeof value === 'string' ? value : null,
)
const [selectedChildIndex, setSelectedChildIndex] = useState<number | null>(null)
const [selectedChildIndex, setSelectedChildIndex] = useState<number>(0)
const noChildBorder = dropdownTitle != null
const isSecret =
'type' in schema &&
schema.type === 'string' &&
@ -47,6 +51,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
schema.format === 'enso-secret'
const { data: secrets } = useBackendQuery(remoteBackend, 'listSecrets', [], { enabled: isSecret })
const autocompleteItems = isSecret ? secrets?.map((secret) => secret.path) ?? null : null
const validityClassName =
isAbsent || getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60'
// NOTE: `enum` schemas omitted for now as they are not yet used.
if ('const' in schema) {
@ -60,12 +66,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
if ('format' in schema && schema.format === 'enso-secret') {
const isValid = typeof value === 'string' && value !== ''
children.push(
<div
className={twMerge(
'w-full rounded-default border-0.5',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)}
>
<div className={twMerge('w-full rounded-default border-0.5', validityClassName)}>
<Autocomplete
items={autocompleteItems ?? []}
itemToKey={(item) => item}
@ -91,8 +92,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'string' ? value : ''}
size={1}
className={twMerge(
'focus-child text w-full 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',
'focus-child h-6 w-full grow rounded-input border-0.5 bg-transparent px-2 read-only:read-only',
validityClassName,
)}
placeholder={getText('enterText')}
onChange={(event) => {
@ -114,8 +115,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child text w-full 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',
'focus-child h-6 w-full grow rounded-input border-0.5 bg-transparent px-2 read-only:read-only',
validityClassName,
)}
placeholder={getText('enterNumber')}
onChange={(event) => {
@ -138,8 +139,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child min-6- text40 w-full 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',
'focus-child h-6 w-full grow rounded-input border-0.5 bg-transparent px-2 read-only:read-only',
validityClassName,
)}
placeholder={getText('enterInteger')}
onChange={(event) => {
@ -154,6 +155,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
case 'boolean': {
children.push(
<Checkbox
name="input"
isReadOnly={readOnly}
isSelected={typeof value === 'boolean' && value}
onChange={onChange}
@ -176,11 +178,16 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
)
if (constantValueOfSchema(defs, schema).length !== 1) {
children.push(
<div className="grid auto-cols-[max-content_auto] items-center gap-json-schema rounded-default border-0.5 border-primary/20 p-json-schema-object-input">
<div
className={twJoin(
'rounded-default',
!noBorder && 'border-0.5 border-primary/20 p-2',
)}
>
{propertyDefinitions.map((definition) => {
const { key, schema: childSchema } = definition
const isOptional = !requiredProperties.includes(key)
const isPresent = value != null && key in value
const isPresent = !isAbsent && value != null && key in value
return constantValueOfSchema(defs, childSchema).length === 1 ?
null
: <Fragment key={key}>
@ -190,7 +197,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
isDisabled={!isOptional}
isActive={!isOptional || isPresent}
className={twMerge(
'col-start-1 inline-block justify-self-start whitespace-nowrap rounded-full px-button-x',
'my-0.5 inline-block justify-self-start whitespace-nowrap rounded-full px-2 text-2xs',
isOptional && 'hover:bg-hover-bg',
)}
onPress={() => {
@ -216,45 +223,48 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
{'title' in childSchema ? String(childSchema.title) : key}
</Button>
{isPresent && (
<div className="col-start-2">
<JSONSchemaInput
readOnly={readOnly}
defs={defs}
schema={childSchema}
path={`${path}/properties/${key}`}
getValidator={getValidator}
// This is SAFE, as `value` is an untyped object.
// eslint-disable-next-line no-restricted-syntax
value={(value as Record<string, unknown>)[key] ?? null}
onChange={(newValue) => {
if (typeof newValue === 'function') {
const unsafeValue: unknown = newValue(
<div>
<JSONSchemaInput
readOnly={readOnly}
defs={defs}
schema={childSchema}
path={`${path}/properties/${key}`}
getValidator={getValidator}
isAbsent={!isPresent}
noBorder={noChildBorder}
// This is SAFE, as `value` is an untyped object.
// eslint-disable-next-line no-restricted-syntax
value={((value ?? {}) as Record<string, unknown>)[key] ?? null}
onChange={(newValue) => {
if (typeof newValue === 'function') {
const unsafeValue: unknown = newValue(
// 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
(value 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!
}
const fullObject =
value ?? constantValueOfSchema(defs, childSchema, true)[0]
onChange(
(
typeof fullObject === 'object' &&
// 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
(value 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!
}
onChange(
(
typeof value === 'object' &&
// 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
(value as Readonly<Record<string, unknown>>)[key] === newValue
) ?
value
: { ...value, [key]: newValue },
)
}}
/>
</div>
)}
(fullObject as Readonly<Record<string, unknown>>)[key] ===
newValue
) ?
fullObject
: { ...fullObject, [key]: newValue },
)
}}
/>
</div>
</Fragment>
})}
</div>,
@ -273,15 +283,15 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
key={schema.$ref}
schema={referencedSchema}
path={schema.$ref}
noBorder={noBorder}
/>,
)
}
}
if ('anyOf' in schema && Array.isArray(schema.anyOf)) {
const childSchemas = schema.anyOf.flatMap(singletonObjectOrNull)
const selectedChildSchema =
selectedChildIndex == null ? null : childSchemas[selectedChildIndex]
const selectedChildPath = `${path}/anyOf/${selectedChildIndex ?? 0}`
const selectedChildSchema = childSchemas[selectedChildIndex]
const selectedChildPath = `${path}/anyOf/${selectedChildIndex}`
const childValue =
selectedChildSchema == null ? [] : constantValueOfSchema(defs, selectedChildSchema)
if (
@ -314,14 +324,18 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
)
children.push(
<div
className={twMerge('flex flex-col gap-json-schema', childValue.length === 0 && 'w-full')}
className={twMerge(
'flex flex-col',
dropdownTitle == null && 'gap-1',
childValue.length === 0 && 'w-full',
)}
>
{dropdownTitle != null ?
<div className="flex h-row items-center">
<div className="h-text w-json-schema-dropdown-title">{dropdownTitle}</div>
{dropdown}
</div>
: dropdown}
{dropdownTitle != null && (
<Text variant="body-sm" className="px-2">
{dropdownTitle}
</Text>
)}
{dropdown}
{selectedChildSchema != null && (
<JSONSchemaInput
key={selectedChildIndex}
@ -330,6 +344,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
schema={selectedChildSchema}
path={selectedChildPath}
getValidator={getValidator}
noBorder={noChildBorder}
value={value}
onChange={onChange}
/>
@ -347,6 +362,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
schema={childSchema}
path={`${path}/allOf/${i}`}
getValidator={getValidator}
noBorder={noChildBorder}
value={value}
onChange={onChange}
/>
@ -356,7 +372,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
return (
children.length === 0 ? null
: children.length === 1 && children[0] != null ? children[0]
: <div className="flex flex-col gap-json-schema">{...children}</div>
: <div className="flex flex-col gap-1">{...children}</div>
)
}
}

View File

@ -5,6 +5,7 @@ import * as modalProvider from '#/providers/ModalProvider'
import FocusRoot from '#/components/styled/FocusRoot'
import { ClearPressResponder } from '#/components/aria'
import * as tailwindVariants from '#/utilities/tailwindVariants'
// =================
@ -43,33 +44,37 @@ export default function Modal(props: ModalProps) {
const { unsetModal } = modalProvider.useSetModal()
return (
<FocusRoot active={!hidden}>
{(innerProps) => (
<div
{...(!hidden ? { 'data-testid': 'modal-background' } : {})}
style={style}
className={MODAL_VARIANTS(variantProps)}
onClick={
onClick ??
((event) => {
if (event.currentTarget === event.target && getSelection()?.type !== 'Range') {
event.stopPropagation()
unsetModal()
}
})
}
onContextMenu={onContextMenu}
{...innerProps}
onKeyDown={(event) => {
innerProps.onKeyDown?.(event)
if (event.key !== 'Escape') {
event.stopPropagation()
// Required so that `Button`s and `Checkbox`es contained inside do not trigger any
// ancestor `DialogTrigger`s.
<ClearPressResponder>
<FocusRoot active={!hidden}>
{(innerProps) => (
<div
{...(!hidden ? { 'data-testid': 'modal-background' } : {})}
style={style}
className={MODAL_VARIANTS(variantProps)}
onClick={
onClick ??
((event) => {
if (event.currentTarget === event.target && getSelection()?.type !== 'Range') {
event.stopPropagation()
unsetModal()
}
})
}
}}
>
{children}
</div>
)}
</FocusRoot>
onContextMenu={onContextMenu}
{...innerProps}
onKeyDown={(event) => {
innerProps.onKeyDown?.(event)
if (event.key !== 'Escape') {
event.stopPropagation()
}
}}
>
{children}
</div>
)}
</FocusRoot>
</ClearPressResponder>
)
}

View File

@ -3,9 +3,12 @@ import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as aria from 'react-aria'
export type * from '@react-types/shared'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria-components'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from '@react-aria/interactions'
export { useTooltipTriggerState, type OverlayTriggerState } from 'react-stately'
// ==================

View File

@ -39,7 +39,8 @@ export default function AssetIcon(props: AssetIconProps) {
return <SvgMask src={KeyIcon} className={className} />
}
case backend.AssetType.specialLoading:
case backend.AssetType.specialEmpty: {
case backend.AssetType.specialEmpty:
case backend.AssetType.specialError: {
// It should not be possible for these to be displayed, but return something anyway.
return <SvgMask src={BlankIcon} className={className} />
}

View File

@ -18,7 +18,6 @@ import {
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
import * as columnModule from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
@ -32,10 +31,9 @@ import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import { isCloudCategory } from '#/layouts/CategorySwitcher/Category'
import * as localBackend from '#/services/LocalBackend'
import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
import * as backendModule from '#/services/Backend'
import { Text } from '#/components/AriaComponents'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
@ -201,7 +199,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const editDescriptionMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
const setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState()
@ -249,11 +246,18 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}, [item.item.id, updateAssetRef])
React.useEffect(() => {
if (isSoleSelected) {
if (isSoleSelected && item.item.id !== driveStore.getState().assetPanelProps?.item?.item.id) {
setAssetPanelProps({ backend, item, setItem })
setIsAssetPanelTemporarilyVisible(false)
}
}, [item, isSoleSelected, backend, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
}, [
item,
isSoleSelected,
backend,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
driveStore,
])
const doDelete = React.useCallback(
(forever = false) => {
@ -262,26 +266,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
[doDeleteRaw, item.item],
)
const doTriggerDescriptionEdit = useEventCallback(() => {
setModal(
<EditAssetDescriptionModal
doChangeDescription={async (description) => {
if (description !== asset.description) {
setAsset(object.merger({ description }))
// eslint-disable-next-line no-restricted-syntax
return editDescriptionMutation.mutateAsync([
asset.id,
{ description, parentDirectoryId: null },
item.item.title,
])
}
}}
initialDescription={asset.description}
/>,
)
})
const clearDragState = React.useCallback(() => {
setIsDraggedOver(false)
setRowState((oldRowState) =>
@ -680,7 +664,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
doCut={doCut}
doPaste={doPaste}
doDelete={doDelete}
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
/>,
)
}
@ -822,7 +805,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
doCut={doCut}
doPaste={doPaste}
doDelete={doDelete}
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
/>
)}
</>
@ -855,9 +837,31 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
)}
>
<img src={BlankIcon} />
<aria.Text className="px-name-column-x placeholder">
<Text className="px-name-column-x placeholder" disableLineHeightCompensation>
{getText('thisFolderIsEmpty')}
</aria.Text>
</Text>
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialError: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twMerge(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(item.depth),
)}
>
<img src={BlankIcon} />
<Text
className="px-name-column-x text-danger placeholder"
disableLineHeightCompensation
>
{getText('thisFolderFailedToFetch')}
</Text>
</div>
</td>
</tr>

View File

@ -1,24 +1,20 @@
/** @file An label that can be applied to an asset. */
import * as React from 'react'
import type { DragEvent, MouseEvent, PropsWithChildren } from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
import type * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import type { PressEvent } from '#/components/aria'
import { Text } from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import * as backend from '#/services/Backend'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useHandleFocusMove } from '#/hooks/focusHooks'
import { useFocusDirection } from '#/providers/FocusDirectionProvider'
import { lChColorToCssColor, type LChColor } from '#/services/Backend'
import { twMerge } from '#/utilities/tailwindMerge'
// =============
// === Label ===
// =============
/** Props for a {@link Label}. */
interface InternalLabelProps extends Readonly<React.PropsWithChildren> {
interface InternalLabelProps extends Readonly<PropsWithChildren> {
readonly 'data-testid'?: string
/** When true, the button is not faded out even when not hovered. */
readonly active?: boolean
@ -28,38 +24,27 @@ interface InternalLabelProps extends Readonly<React.PropsWithChildren> {
/** When true, the button cannot be clicked. */
readonly isDisabled?: boolean
readonly draggable?: boolean
readonly color: backend.LChColor
readonly color: LChColor
readonly title?: string
readonly className?: string
readonly onPress: (event: aria.PressEvent | React.MouseEvent<HTMLButtonElement>) => void
readonly onContextMenu?: (event: React.MouseEvent<HTMLElement>) => void
readonly onDragStart?: (event: React.DragEvent<HTMLElement>) => void
readonly onPress: (event: MouseEvent<HTMLButtonElement> | PressEvent) => void
readonly onContextMenu?: (event: MouseEvent<HTMLElement>) => void
readonly onDragStart?: (event: DragEvent<HTMLElement>) => void
}
/** An label that can be applied to an asset. */
export default function Label(props: InternalLabelProps) {
const { active = false, isDisabled = false, color, negated = false, draggable, title } = props
const { className = 'text-tag-text', onPress, onDragStart, onContextMenu } = props
const { onPress, onDragStart, onContextMenu } = props
const { children: childrenRaw } = props
const focusDirection = focusDirectionProvider.useFocusDirection()
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
const textClass =
/\btext-/.test(className) ?
'' // eslint-disable-next-line @typescript-eslint/no-magic-numbers
: color.lightness <= 50 ? 'text-tag-text'
: 'text-primary'
const children =
typeof childrenRaw !== 'string' ? childrenRaw : (
<ariaComponents.Text truncate="1" className="max-w-24" color="invert" variant="body">
{childrenRaw}
</ariaComponents.Text>
)
const focusDirection = useFocusDirection()
const handleFocusMove = useHandleFocusMove(focusDirection)
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const isLight = color.lightness > 50
return (
<FocusRing within placement="after">
<div
className={tailwindMerge.twMerge(
className={twMerge(
'relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-inherit',
negated && 'after:!outline-offset-0',
)}
@ -72,14 +57,12 @@ export default function Label(props: InternalLabelProps) {
draggable={draggable}
title={title}
disabled={isDisabled}
className={tailwindMerge.twMerge(
'focus-child relative flex items-center whitespace-nowrap rounded-inherit px-[7px] opacity-75 transition-all after:pointer-events-none after:absolute after:inset after:rounded-full hover:opacity-100 focus:opacity-100',
className={twMerge(
'focus-child relative flex h-6 items-center whitespace-nowrap rounded-inherit px-[7px] opacity-75 transition-all after:pointer-events-none after:absolute after:inset after:rounded-full hover:opacity-100 focus:opacity-100',
active && 'active',
negated && 'after:border-2 after:border-delete',
className,
textClass,
)}
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
style={{ backgroundColor: lChColorToCssColor(color) }}
onClick={(event) => {
event.stopPropagation()
onPress(event)
@ -90,7 +73,17 @@ export default function Label(props: InternalLabelProps) {
onContextMenu={onContextMenu}
onKeyDown={handleFocusMove}
>
{children}
{typeof childrenRaw !== 'string' ?
childrenRaw
: <Text
truncate="1"
className="max-w-24"
color={isLight ? 'primary' : 'invert'}
variant="body"
>
{childrenRaw}
</Text>
}
</button>
</div>
</FocusRing>

View File

@ -29,6 +29,7 @@ const ASSET_TYPE_TO_TEXT_ID: Readonly<Record<backendModule.AssetType, text.TextI
[backendModule.AssetType.file]: 'fileAssetType',
[backendModule.AssetType.secret]: 'secretAssetType',
[backendModule.AssetType.specialEmpty]: 'specialEmptyAssetType',
[backendModule.AssetType.specialError]: 'specialErrorAssetType',
[backendModule.AssetType.specialLoading]: 'specialLoadingAssetType',
[backendModule.AssetType.datalink]: 'datalinkAssetType',
} satisfies { [Type in backendModule.AssetType]: `${Type}AssetType` }

View File

@ -80,7 +80,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
// A rectangle covering the entire screen
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
// Move to top left of label
`M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` +
`M${originalLeft + r} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` +
// Top straight edge of label
`h${LABEL_STRAIGHT_WIDTH_PX}` +
// Right semicircle of label

View File

@ -20,9 +20,10 @@ const CAPITALIZED_ASSET_TYPE: Readonly<Record<backend.AssetType, string>> = {
[backend.AssetType.file]: 'File',
[backend.AssetType.datalink]: 'Datalink',
[backend.AssetType.secret]: 'Secret',
// These assets should never be visible, since they don't have columns.
[backend.AssetType.specialEmpty]: 'Empty asset',
[backend.AssetType.specialLoading]: 'Loading asset',
// These assets should never be visible, since they don't appear in the UI at all.
[backend.AssetType.specialEmpty]: '',
[backend.AssetType.specialError]: '',
[backend.AssetType.specialLoading]: '',
}
/** Data needed to display a single permission type. */

View File

@ -17,7 +17,9 @@ import StatelessSpinner, * as spinner from '#/components/StatelessSpinner'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import { useBackendQuery } from '#/hooks/backendHooks'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useMemo } from 'react'
// =================
// === Constants ===
@ -89,6 +91,18 @@ export default function ProjectIcon(props: ProjectIconProps) {
const isOtherUserUsingProject =
isCloud && itemProjectState.openedBy != null && itemProjectState.openedBy !== user.email
const { data: users } = useBackendQuery(backend, 'listUsers', [], {
enabled: isOtherUserUsingProject,
})
const userOpeningProject = useMemo(
() =>
!isOtherUserUsingProject ? null : (
users?.find((otherUser) => otherUser.email === itemProjectState.openedBy)
),
[isOtherUserUsingProject, itemProjectState.openedBy, users],
)
const userOpeningProjectTooltip =
userOpeningProject == null ? null : getText('xIsUsingTheProject', userOpeningProject.name)
const state = (() => {
// Project is closed, show open button
@ -159,7 +173,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
extraClickZone="xsmall"
isDisabled={isDisabled || isOtherUserUsingProject}
icon={StopIcon}
aria-label={getText('stopExecution')}
aria-label={userOpeningProjectTooltip ?? getText('stopExecution')}
tooltipPlacement="left"
className={tailwindMerge.twJoin(isRunningInBackground && 'text-green')}
{...(isOtherUserUsingProject ? { title: getText('otherUserIsUsingProjectError') } : {})}
@ -184,7 +198,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
extraClickZone="xsmall"
isDisabled={isDisabled || isOtherUserUsingProject}
icon={StopIcon}
aria-label={getText('stopExecution')}
aria-label={userOpeningProjectTooltip ?? getText('stopExecution')}
tooltipPlacement="left"
className={tailwindMerge.twMerge(isRunningInBackground && 'text-green')}
onPress={doCloseProject}
@ -204,7 +218,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
variant="icon"
extraClickZone="xsmall"
icon={ArrowUpIcon}
aria-label={getText('openInEditor')}
aria-label={userOpeningProjectTooltip ?? getText('openInEditor')}
isDisabled={isDisabled}
tooltipPlacement="right"
onPress={doOpenProjectTab}

View File

@ -1,6 +1,4 @@
/** @file A component that renders the modal instance from the modal React Context. */
import * as React from 'react'
import { DialogTrigger } from '#/components/AriaComponents'
import * as modalProvider from '#/providers/ModalProvider'
import { AnimatePresence, motion } from 'framer-motion'

View File

@ -74,11 +74,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
active={!temporarilyRemovedLabels.has(label)}
isDisabled={temporarilyRemovedLabels.has(label)}
negated={temporarilyRemovedLabels.has(label)}
className={
temporarilyRemovedLabels.has(label) ?
'relative before:absolute before:inset before:h-full before:w-full before:rounded-full before:border-2 before:border-delete'
: ''
}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
@ -129,7 +124,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
isDisabled
key={label}
color={labelsByName.get(label)?.color ?? backendModule.COLORS[0]}
className="pointer-events-none"
onPress={() => {}}
>
{label}
@ -148,7 +142,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
key={uniqueString.uniqueString()}
backend={backend}
item={asset}
setItem={setAsset}
eventTarget={plusButtonRef.current}
/>,
)

View File

@ -38,7 +38,8 @@ export default function AssetNameColumn(props: AssetNameColumnProps) {
return <SecretNameColumn {...props} item={item} />
}
case backendModule.AssetType.specialLoading:
case backendModule.AssetType.specialEmpty: {
case backendModule.AssetType.specialEmpty:
case backendModule.AssetType.specialError: {
// Special rows do not display columns at all.
return <></>
}

View File

@ -96,8 +96,10 @@ export function getColumnList(
Column.modified,
isCloud && (isEnterprise || isTrash) && Column.sharedWith,
isCloud && Column.labels,
isCloud && Column.accessedByProjects,
isCloud && Column.accessedData,
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1525
// Bring back these columns when they are ready for use again.
// isCloud && Column.accessedByProjects,
// isCloud && Column.accessedData,
isCloud && Column.docs,
]
return columns.flatMap((column) => (column !== false ? [column] : []))

View File

@ -133,6 +133,7 @@ const INVALIDATION_MAP: Partial<
changeUserGroup: ['listUsers'],
createTag: ['listTags'],
deleteTag: ['listTags'],
associateTag: ['listDirectory'],
acceptInvitation: [INVALIDATE_ALL_QUERIES],
declineInvitation: ['usersMe'],
createProject: ['listDirectory'],

View File

@ -0,0 +1,135 @@
/** @file Hooks for reactively watching node dimensions. */
import { useCallback, useLayoutEffect, useState } from 'react'
/** Dimensions object for {@link useDimensions}. */
interface DimensionObject {
readonly width: number
readonly height: number
readonly top: number
readonly left: number
readonly x: number
readonly y: number
readonly right: number
readonly bottom: number
}
/** Arguments for {@link useDimensions}. */
interface UseDimensionsArgs {
readonly liveMeasure?: boolean
readonly observePosition?: boolean
}
/** Turn a bounding box to a {@link DimensionObject}. */
function getDimensionObject(node: HTMLElement | SVGElement): DimensionObject {
const rect = node.getBoundingClientRect()
return {
width: rect.width,
height: rect.height,
// eslint-disable-next-line no-restricted-syntax
top: 'y' in rect ? rect.y : (rect as { top: number }).top,
// eslint-disable-next-line no-restricted-syntax
left: 'x' in rect ? rect.x : (rect as { left: number }).left,
// eslint-disable-next-line no-restricted-syntax
x: 'x' in rect ? rect.x : (rect as { left: number }).left,
// eslint-disable-next-line no-restricted-syntax
y: 'y' in rect ? rect.y : (rect as { top: number }).top,
right: rect.right,
bottom: rect.bottom,
}
}
/** Reactively watch dimensions of a node. */
export function useDimensions({
liveMeasure = true,
observePosition = true,
}: UseDimensionsArgs = {}): [
ref: (node: HTMLElement | SVGElement | null) => void,
dimensions: DimensionObject,
element: HTMLElement | SVGElement | null,
] {
const [dimensions, setDimensions] = useState({
width: 0,
height: 0,
top: 0,
left: 0,
x: 0,
y: 0,
right: 0,
bottom: 0,
})
const [node, setNode] = useState<HTMLElement | SVGElement | null>(null)
const ref = useCallback((newNode: HTMLElement | SVGElement | null) => {
setNode(newNode)
}, [])
useLayoutEffect(() => {
if (node) {
const measure = () => {
requestAnimationFrame(() => {
setDimensions(getDimensionObject(node))
})
}
measure()
// See https://stackoverflow.com/a/74481932 for the `IntersectionObserver` code.
if (liveMeasure) {
const resizeObserver = new ResizeObserver(measure)
const child = !observePosition ? null : node.appendChild(document.createElement('div'))
const updateChildPosition = (boundingBox = child?.getBoundingClientRect()) => {
if (child && boundingBox) {
const wasChanged = boundingBox.left !== -1 || boundingBox.top !== -1
child.style.marginLeft = `${parseFloat(child.style.marginLeft || '0') - boundingBox.left - 1}px`
child.style.marginTop = `${parseFloat(child.style.marginTop || '0') - boundingBox.top - 1}px`
if (wasChanged) {
// On Firefox, `IntersectionObserver`s fire inconsistently when an element's
// position is changed too quickly.
requestAnimationFrame(() => {
updateChildPosition()
measure()
})
}
}
}
if (child) {
child.classList.add('__use-dimensions-observer')
child.style.position = 'fixed'
child.style.pointerEvents = 'none'
child.style.height = '2px'
child.style.width = '2px'
updateChildPosition()
}
const intersectionObserver =
!child ? null : (
new IntersectionObserver(
(entries) => {
if (entries[0]) {
measure()
updateChildPosition() // entries[0].boundingClientRect)
}
},
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
{ threshold: [0, 0.125, 0.375, 0.625, 0.875, 1] },
)
)
window.addEventListener('resize', measure)
window.addEventListener('scroll', measure)
resizeObserver.observe(node)
if (child) {
intersectionObserver?.observe(child)
}
return () => {
child?.remove()
window.removeEventListener('resize', measure)
window.removeEventListener('scroll', measure)
resizeObserver.disconnect()
intersectionObserver?.disconnect()
}
}
}
}, [liveMeasure, node, observePosition])
return [ref, dimensions, node]
}

View File

@ -0,0 +1,114 @@
/** @file Hooks for showing an overlay with a cutout for a rectangular element. */
import { useEffect, useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
import { createPortal } from 'react-dom'
import { useDimensions } from '#/hooks/dimensionsHooks'
import { convertCSSUnitString } from '#/utilities/convertCSSUnits'
/** Default padding around the spotlight element. */
const DEFAULT_PADDING_PX = 8
// eslint-disable-next-line no-restricted-syntax
const BACKGROUND_ELEMENT = document.getElementsByClassName('enso-spotlight')[0] as
| HTMLElement
| SVGElement
| undefined
/** Props for {@link useSpotlight}. */
export interface SpotlightOptions {
readonly enabled: boolean
readonly ref: RefObject<HTMLElement | SVGElement | null>
readonly close: () => void
readonly backgroundElement?: HTMLElement
readonly paddingPx?: number | undefined
}
/** A hook for showing an overlay with a cutout for a rectangular element. */
export function useSpotlight(options: SpotlightOptions) {
const { enabled, ref, close, backgroundElement: backgroundElementRaw } = options
const { paddingPx = DEFAULT_PADDING_PX } = options
const backgroundElement = backgroundElementRaw ?? BACKGROUND_ELEMENT
const spotlightElement =
!enabled || !backgroundElement ?
null
: <Spotlight
close={close}
element={ref}
backgroundElement={backgroundElement}
paddingPx={paddingPx}
/>
const style = { position: 'relative', zIndex: 3 } satisfies CSSProperties
return { spotlightElement, props: { style } }
}
/** Props for a {@link Spotlight}. */
interface SpotlightProps {
readonly element: RefObject<HTMLElement | SVGElement | null>
readonly close: () => void
readonly backgroundElement: HTMLElement | SVGElement
readonly paddingPx?: number | undefined
}
/** A spotlight element. */
function Spotlight(props: SpotlightProps) {
const { element, close, backgroundElement, paddingPx = 0 } = props
const [dimensionsRef, { top: topRaw, left: leftRaw, height, width }] = useDimensions()
const top = topRaw - paddingPx
const left = leftRaw - paddingPx
const [borderRadius, setBorderRadius] = useState(0)
const r = Math.min(borderRadius, height / 2 + paddingPx, width / 2 + paddingPx)
const straightWidth = Math.max(0, width + paddingPx * 2 - borderRadius * 2)
const straightHeight = Math.max(0, height + paddingPx * 2 - borderRadius * 2)
useEffect(() => {
if (element.current) {
dimensionsRef(element.current)
}
}, [dimensionsRef, element])
useLayoutEffect(() => {
if (element.current) {
const sizeString = getComputedStyle(element.current).borderRadius
setBorderRadius(convertCSSUnitString(sizeString, 'px', element.current).number)
}
}, [element])
const clipPath =
// A rectangle covering the entire screen
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
// Move to top left
`M${left + r} ${top}` +
// Top edge
`h${straightWidth}` +
// Top right arc
(r !== 0 ? `a${r} ${r} 0 0 1 ${r} ${r}` : '') +
// Right edge
`v${straightHeight}` +
// Bottom right arc
(r !== 0 ? `a${r} ${r} 0 0 1 -${r} ${r}` : '') +
// Bottom edge
`h-${straightWidth}` +
// Bottom left arc
(r !== 0 ? `a${r} ${r} 0 0 1 -${r} -${r}` : '') +
// Left edge
`v-${straightHeight}` +
// Top left arc
(r !== 0 ? `a${r} ${r} 0 0 1 ${r} -${r}` : '') +
'Z")'
return createPortal(
<div
onClick={close}
style={{
position: 'absolute',
zIndex: 2,
height: '100vh',
width: '100vw',
backgroundColor: 'lch(0 0 0 / 25%)',
clipPath,
}}
/>,
backgroundElement,
)
}

View File

@ -32,11 +32,14 @@ import Separator from '#/components/styled/Separator'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import ManageLabelsModal from '#/modals/ManageLabelsModal'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import * as backendModule from '#/services/Backend'
import * as localBackendModule from '#/services/LocalBackend'
import {
useSetAssetPanelProps,
useSetIsAssetPanelTemporarilyVisible,
} from '#/providers/DriveProvider'
import { normalizePath } from '#/utilities/fileInfo'
import { mapNonNullish } from '#/utilities/nullable'
import * as object from '#/utilities/object'
@ -56,7 +59,6 @@ export interface AssetContextMenuProps {
readonly doDelete: () => void
readonly doCopy: () => void
readonly doCut: () => void
readonly doTriggerDescriptionEdit: () => void
readonly doPaste: (
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId,
@ -66,30 +68,36 @@ export interface AssetContextMenuProps {
/** The context menu for an arbitrary {@link backendModule.Asset}. */
export default function AssetContextMenu(props: AssetContextMenuProps) {
const { innerProps, rootDirectoryId, event, eventTarget, hidden = false } = props
const { doTriggerDescriptionEdit, doCopy, doCut, doPaste, doDelete } = props
const { doCopy, doCut, doPaste, doDelete } = props
const { item, setItem, state, setRowState } = innerProps
const { backend, category, hasPasteData, pasteData, nodeMap } = state
const { user } = authProvider.useFullUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { setModal } = modalProvider.useSetModal()
const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const setAssetPanelProps = useSetAssetPanelProps()
const openProject = projectHooks.useOpenProject()
const closeProject = projectHooks.useCloseProject()
const openProjectMutation = projectHooks.useOpenProjectMutation()
const asset = item.item
const self = permissions.tryFindSelfPermission(user, asset.permissions)
const isCloud = categoryModule.isCloudCategory(category)
const path =
const pathRaw =
category.type === 'recent' || category.type === 'trash' ? null
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
: asset.type === backendModule.AssetType.project ?
mapNonNullish(localBackend?.getProjectPath(asset.id) ?? null, normalizePath)
: normalizePath(localBackendModule.extractTypeAndId(asset.id).id)
const path =
pathRaw == null ? null
: isCloud ? encodeURI(pathRaw)
: pathRaw
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
@ -158,7 +166,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
action="undelete"
label={getText('restoreFromTrashShortcut')}
doAction={() => {
unsetModal()
dispatchAssetEvent({ type: AssetEventType.restore, ids: new Set([asset.id]) })
}}
/>
@ -188,7 +195,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="useInNewProject"
doAction={() => {
unsetModal()
dispatchAssetListEvent({
type: AssetListEventType.newProject,
parentId: item.directoryId,
@ -208,7 +214,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="open"
doAction={() => {
unsetModal()
openProject({
id: asset.id,
title: asset.title,
@ -223,7 +228,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="run"
doAction={() => {
unsetModal()
openProjectMutation.mutate({
id: asset.id,
title: asset.title,
@ -239,7 +243,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="openInFileBrowser"
doAction={() => {
unsetModal()
systemApi.showItemInFolder(path)
}}
/>
@ -252,7 +255,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="close"
doAction={() => {
unsetModal()
closeProject({
id: asset.id,
title: asset.title,
@ -267,7 +269,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="uploadToCloud"
doAction={async () => {
unsetModal()
if (remoteBackend == null) {
toastAndLog('offlineUploadFilesError')
} else {
@ -307,31 +308,29 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
action="rename"
doAction={() => {
setRowState(object.merger({ isEditingName: true }))
unsetModal()
}}
/>
)}
{asset.type === backendModule.AssetType.secret &&
{(asset.type === backendModule.AssetType.secret ||
asset.type === backendModule.AssetType.datalink) &&
canEditThisAsset &&
remoteBackend != null && (
<ContextMenuEntry
hidden={hidden}
action="edit"
doAction={() => {
setModal(
<UpsertSecretModal
defaultOpen
id={asset.id}
name={asset.title}
doCreate={async (_name, value) => {
try {
await remoteBackend.updateSecret(asset.id, { value }, asset.title)
} catch (error) {
toastAndLog(null, error)
}
}}
/>,
)
setIsAssetPanelTemporarilyVisible(true)
const assetPanelProps = { backend, item, setItem }
switch (asset.type) {
case backendModule.AssetType.secret: {
setAssetPanelProps({ ...assetPanelProps, spotlightOn: 'secret' })
break
}
case backendModule.AssetType.datalink: {
setAssetPanelProps({ ...assetPanelProps, spotlightOn: 'datalink' })
break
}
}
}}
/>
)}
@ -341,7 +340,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
action="editDescription"
label={getText('editDescriptionShortcut')}
doAction={() => {
doTriggerDescriptionEdit()
setIsAssetPanelTemporarilyVisible(true)
setAssetPanelProps({ backend, item, setItem, spotlightOn: 'description' })
}}
/>
)}
@ -371,7 +371,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
/>,
)
} else {
unsetModal()
doDelete()
}
} else {
@ -425,12 +424,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
action="label"
doAction={() => {
setModal(
<ManageLabelsModal
backend={backend}
item={asset}
setItem={setAsset}
eventTarget={eventTarget}
/>,
<ManageLabelsModal backend={backend} item={asset} eventTarget={eventTarget} />,
)
}}
/>
@ -441,7 +435,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="duplicate"
doAction={() => {
unsetModal()
dispatchAssetListEvent({
type: AssetListEventType.copy,
newParentId: item.directoryId,
@ -456,10 +449,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
<ContextMenuEntry
hidden={hidden}
action="copyAsPath"
doAction={() => {
unsetModal()
copyMutation.mutate()
}}
doAction={copyMutation.mutateAsync}
/>
)}
{!isRunningProject && !isOtherUserUsingProject && (
@ -473,11 +463,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
isDisabled={asset.type === backendModule.AssetType.secret}
action="download"
doAction={() => {
unsetModal()
dispatchAssetEvent({
type: AssetEventType.download,
ids: new Set([asset.id]),
})
dispatchAssetEvent({ type: AssetEventType.download, ids: new Set([asset.id]) })
}}
/>
)}

View File

@ -1,36 +1,32 @@
/** @file A panel containing the description and settings for an asset. */
import * as React from 'react'
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from 'react'
import * as z from 'zod'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetProjectSessions from '#/layouts/AssetProjectSessions'
import AssetProperties from '#/layouts/AssetProperties'
import { TabPanel, Tabs } from '#/components/aria'
import ProjectSessions from '#/layouts/AssetProjectSessions'
import AssetProperties, { type AssetPropertiesSpotlight } from '#/layouts/AssetProperties'
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
import * as ariaComponents from '#/components/AriaComponents'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import TabBar, { Tab } from '#/layouts/TabBar'
import { useAssetPanelProps, useIsAssetPanelVisible } from '#/providers/DriveProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import { AssetType, BackendType } from '#/services/Backend'
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
import LocalStorage from '#/utilities/LocalStorage'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { twMerge } from '#/utilities/tailwindMerge'
// =====================
// === AssetPanelTab ===
// =====================
const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules'] as const
const TABS_SCHEMA = z.enum(ASSET_PANEL_TABS)
/** Determines the content of the {@link AssetPanel}. */
enum AssetPanelTab {
properties = 'properties',
versions = 'versions',
projectSessions = 'projectSessions',
}
type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]
// ============================
// === Global configuration ===
@ -45,7 +41,7 @@ declare module '#/utilities/LocalStorage' {
}
LocalStorage.register({
assetPanelTab: { schema: z.nativeEnum(AssetPanelTab) },
assetPanelTab: { schema: z.enum(ASSET_PANEL_TABS) },
assetPanelWidth: { schema: z.number().int() },
})
@ -56,13 +52,14 @@ LocalStorage.register({
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly item: assetTreeNode.AnyAssetTreeNode | null
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>> | null
readonly item: AnyAssetTreeNode | null
readonly setItem: Dispatch<SetStateAction<AnyAssetTreeNode>> | null
readonly spotlightOn?: AssetPropertiesSpotlight
}
/** Props for an {@link AssetPanel}. */
export interface AssetPanelProps {
readonly backendType: backendModule.BackendType
readonly backendType: BackendType
readonly category: Category
}
@ -73,37 +70,31 @@ export default function AssetPanel(props: AssetPanelProps) {
const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null
const { backend, item, setItem } = contextProps ?? {}
const isReadonly = category.type === 'trash'
const isCloud = backend?.type === backendModule.BackendType.remote
const isCloud = backend?.type === BackendType.remote
const isVisible = useIsAssetPanelVisible()
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized)
const { getText } = useText()
const { localStorage } = useLocalStorage()
const [initialized, setInitialized] = useState(false)
const initializedRef = useRef(initialized)
initializedRef.current = initialized
const [tabRaw, setTab] = React.useState(
() => localStorage.get('assetPanelTab') ?? AssetPanelTab.properties,
)
const [tabRaw, setTab] = useState(() => localStorage.get('assetPanelTab') ?? 'settings')
const tab = (() => {
if (!isCloud) {
return AssetPanelTab.properties
return 'settings'
} else if (
(item?.item.type === backendModule.AssetType.secret ||
item?.item.type === backendModule.AssetType.directory) &&
tabRaw === AssetPanelTab.versions
(item?.item.type === AssetType.secret || item?.item.type === AssetType.directory) &&
tabRaw === 'versions'
) {
return AssetPanelTab.properties
} else if (
item?.item.type !== backendModule.AssetType.project &&
tabRaw === AssetPanelTab.projectSessions
) {
return AssetPanelTab.properties
return 'settings'
} else if (item?.item.type !== AssetType.project && tabRaw === 'sessions') {
return 'settings'
} else {
return tabRaw
}
})()
React.useEffect(() => {
useEffect(() => {
// This prevents secrets and directories always setting the tab to `properties`
// (because they do not support the `versions` tab).
if (initializedRef.current) {
@ -111,79 +102,64 @@ export default function AssetPanel(props: AssetPanelProps) {
}
}, [tabRaw, localStorage])
React.useEffect(() => {
useEffect(() => {
setInitialized(true)
}, [])
return (
<div
className={tailwindMerge.twMerge(
className={twMerge(
'flex flex-col overflow-hidden transition-min-width duration-side-panel ease-in-out',
isVisible ? 'min-w-side-panel' : 'min-w',
isVisible ? 'min-w-side-panel' : 'min-w-0',
)}
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
}}
>
<div
<Tabs
data-testid="asset-panel"
className={tailwindMerge.twMerge(
'pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel bg-invert p-4 pl-asset-panel-l transition-[box-shadow] clip-path-left-shadow',
className={twMerge(
'absolute flex h-full w-asset-panel flex-col bg-invert transition-[box-shadow] clip-path-left-shadow',
isVisible ? 'shadow-softer' : '',
)}
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
selectedKey={tab}
onSelectionChange={(newPage) => {
const validated = TABS_SCHEMA.safeParse(newPage)
if (validated.success) {
setTab(validated.data)
}
}}
>
<ariaComponents.ButtonGroup className="mt-0.5 grow-0 basis-8">
{isCloud &&
item != null &&
item.item.type !== backendModule.AssetType.secret &&
item.item.type !== backendModule.AssetType.directory && (
<ariaComponents.Button
size="medium"
variant="outline"
className={tailwindMerge.twMerge(
'pointer-events-auto disabled:opacity-100',
tab === AssetPanelTab.versions && 'bg-primary/[8%] opacity-100',
)}
onPress={() => {
setTab((oldTab) =>
oldTab === AssetPanelTab.versions ?
AssetPanelTab.properties
: AssetPanelTab.versions,
)
}}
>
{getText('versions')}
</ariaComponents.Button>
)}
{isCloud && item != null && item.item.type === backendModule.AssetType.project && (
<ariaComponents.Button
size="medium"
variant="outline"
className={tailwindMerge.twMerge(
'pointer-events-auto disabled:opacity-100',
tab === AssetPanelTab.projectSessions && 'bg-primary/[8%] opacity-100',
)}
onPress={() => {
setTab((oldTab) =>
oldTab === AssetPanelTab.projectSessions ?
AssetPanelTab.properties
: AssetPanelTab.projectSessions,
)
}}
>
{getText('projectSessions')}
</ariaComponents.Button>
)}
{/* Spacing. The top right asset and user bars overlap this area. */}
<div className="grow" />
</ariaComponents.ButtonGroup>
{item == null || setItem == null || backend == null ?
<div className="grid grow place-items-center text-lg">
{getText('selectExactlyOneAssetToViewItsDetails')}
</div>
: <>
{tab === AssetPanelTab.properties && (
<div className="h-4 bg-primary/5" />
<TabBar className="grow-0">
<Tab id="settings" labelId="settings" isActive={tab === 'settings'} icon={null}>
{getText('settings')}
</Tab>
{isCloud &&
item.item.type !== AssetType.secret &&
item.item.type !== AssetType.directory && (
<Tab id="versions" labelId="versions" isActive={tab === 'versions'} icon={null}>
{getText('versions')}
</Tab>
)}
{isCloud && item.item.type === AssetType.project && (
<Tab
id="sessions"
labelId="projectSessions"
isActive={tab === 'sessions'}
icon={null}
>
{getText('projectSessions')}
</Tab>
)}
</TabBar>
<TabPanel id="settings" className="p-4 pl-asset-panel-l">
<AssetProperties
key={item.item.id}
backend={backend}
@ -191,16 +167,20 @@ export default function AssetPanel(props: AssetPanelProps) {
item={item}
setItem={setItem}
category={category}
spotlightOn={contextProps?.spotlightOn}
/>
</TabPanel>
<TabPanel id="versions" className="p-4 pl-asset-panel-l">
<AssetVersions backend={backend} item={item} />
</TabPanel>
{item.type === AssetType.project && (
<TabPanel id="sessions" className="p-4 pl-asset-panel-l">
<ProjectSessions backend={backend} item={item} />
</TabPanel>
)}
{tab === AssetPanelTab.versions && <AssetVersions backend={backend} item={item} />}
{tab === AssetPanelTab.projectSessions &&
item.type === backendModule.AssetType.project && (
<AssetProjectSessions backend={backend} item={item} />
)}
</>
}
</div>
</Tabs>
</div>
)
}

View File

@ -33,7 +33,7 @@ export default function AssetProjectSession(props: AssetProjectSessionProps) {
</div>
<div className="flex items-center gap-1">
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button isActive icon={LogsIcon} aria-label={getText('showLogs')} />
<Button variant="icon" isActive icon={LogsIcon} aria-label={getText('showLogs')} />
<ProjectLogsModal
isOpen={isOpen}

View File

@ -17,7 +17,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import DatalinkInput from '#/components/dashboard/DatalinkInput'
import { DatalinkFormInput } from '#/components/dashboard/DatalinkInput'
import Label from '#/components/dashboard/Label'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
@ -25,8 +25,14 @@ import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import * as localBackendModule from '#/services/LocalBackend'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSpotlight } from '#/hooks/spotlightHooks'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useDriveStore, useSetAssetPanelProps } from '#/providers/DriveProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import { normalizePath } from '#/utilities/fileInfo'
import { mapNonNullish } from '#/utilities/nullable'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
@ -34,6 +40,9 @@ import * as permissions from '#/utilities/permissions'
// === AssetProperties ===
// =======================
/** Possible elements in this screen to spotlight on. */
export type AssetPropertiesSpotlight = 'datalink' | 'description' | 'secret'
/** Props for an {@link AssetPropertiesProps}. */
export interface AssetPropertiesProps {
readonly backend: Backend
@ -41,29 +50,66 @@ export interface AssetPropertiesProps {
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
readonly category: Category
readonly isReadonly?: boolean
readonly spotlightOn: AssetPropertiesSpotlight | undefined
}
/** Display and modify the properties of an asset. */
export default function AssetProperties(props: AssetPropertiesProps) {
const { backend, item, setItem, category } = props
const { backend, item, setItem, category, spotlightOn } = props
const { isReadonly = false } = props
const setAssetPanelProps = useSetAssetPanelProps()
const closeSpotlight = useEventCallback(() => {
const assetPanelProps = driveStore.getState().assetPanelProps
if (assetPanelProps != null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { spotlightOn: unusedSpotlightOn, ...rest } = assetPanelProps
setAssetPanelProps(rest)
}
})
const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const localBackend = backendProvider.useLocalBackend()
const [isEditingDescription, setIsEditingDescription] = React.useState(false)
const [isEditingDescriptionRaw, setIsEditingDescriptionRaw] = React.useState(false)
const isEditingDescription = isEditingDescriptionRaw || spotlightOn === 'description'
const setIsEditingDescription = React.useCallback(
(valueOrUpdater: React.SetStateAction<boolean>) => {
setIsEditingDescriptionRaw((currentValue) => {
if (typeof valueOrUpdater === 'function') {
valueOrUpdater = valueOrUpdater(currentValue)
}
if (!valueOrUpdater) {
closeSpotlight()
}
return valueOrUpdater
})
},
[closeSpotlight],
)
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null)
const [description, setDescription] = React.useState('')
const [datalinkValue, setDatalinkValue] = React.useState<NonNullable<unknown> | null>(null)
const [editedDatalinkValue, setEditedDatalinkValue] = React.useState<NonNullable<unknown> | null>(
datalinkValue,
)
const [isDatalinkFetched, setIsDatalinkFetched] = React.useState(false)
const isDatalinkSubmittable = React.useMemo(
() => datalinkValidator.validateDatalink(datalinkValue),
[datalinkValue],
)
const driveStore = useDriveStore()
const descriptionRef = React.useRef<HTMLDivElement>(null)
const descriptionSpotlight = useSpotlight({
ref: descriptionRef,
enabled: spotlightOn === 'description',
close: closeSpotlight,
})
const secretRef = React.useRef<HTMLDivElement>(null)
const secretSpotlight = useSpotlight({
ref: secretRef,
enabled: spotlightOn === 'secret',
close: closeSpotlight,
})
const datalinkRef = React.useRef<HTMLDivElement>(null)
const datalinkSpotlight = useSpotlight({
ref: datalinkRef,
enabled: spotlightOn === 'datalink',
close: closeSpotlight,
})
const labels = useBackendQuery(backend, 'listTags', []).data ?? []
const self = permissions.tryFindSelfPermission(user, item.item.permissions)
@ -72,18 +118,23 @@ export default function AssetProperties(props: AssetPropertiesProps) {
ownsThisAsset ||
self?.permission === permissions.PermissionAction.admin ||
self?.permission === permissions.PermissionAction.edit
const isDatalink = item.item.type === backendModule.AssetType.datalink
const isDatalinkDisabled = datalinkValue === editedDatalinkValue || !isDatalinkSubmittable
const isSecret = item.type === backendModule.AssetType.secret
const isDatalink = item.type === backendModule.AssetType.datalink
const isCloud = backend.type === backendModule.BackendType.remote
const path =
isCloud ? null
const pathRaw =
category.type === 'recent' || category.type === 'trash' ? null
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
: item.item.type === backendModule.AssetType.project ?
localBackend?.getProjectPath(item.item.id) ?? null
: localBackendModule.extractTypeAndId(item.item.id).id
mapNonNullish(localBackend?.getProjectPath(item.item.id) ?? null, normalizePath)
: normalizePath(localBackendModule.extractTypeAndId(item.item.id).id)
const path =
pathRaw == null ? null
: isCloud ? encodeURI(pathRaw)
: pathRaw
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const getDatalink = getDatalinkMutation.mutateAsync
React.useEffect(() => {
@ -95,7 +146,6 @@ export default function AssetProperties(props: AssetPropertiesProps) {
if (item.item.type === backendModule.AssetType.datalink) {
const value = await getDatalink([item.item.id, item.item.title])
setDatalinkValue(value)
setEditedDatalinkValue(value)
setIsDatalinkFetched(true)
}
})()
@ -123,7 +173,14 @@ export default function AssetProperties(props: AssetPropertiesProps) {
return (
<>
<div className="pointer-events-auto flex flex-col items-start gap-side-panel">
{descriptionSpotlight.spotlightElement}
{secretSpotlight.spotlightElement}
{datalinkSpotlight.spotlightElement}
<div
ref={descriptionRef}
className="pointer-events-auto flex flex-col items-start gap-side-panel rounded-default"
{...descriptionSpotlight.props}
>
<aria.Heading
level={2}
className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug"
@ -155,9 +212,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
setQueuedDescripion(null)
}
}}
autoFocus
value={description}
className="w-full resize-none rounded-default border-0.5 border-primary/20 p-2"
onBlur={doEditDescription}
className="w-full resize-none rounded-default border-0.5 border-primary/20 p-2 outline-2 outline-offset-2 transition-[border-color,outline] focus-within:outline focus-within:outline-offset-0"
onChange={(event) => {
setDescription(event.currentTarget.value)
}}
@ -186,31 +243,6 @@ export default function AssetProperties(props: AssetPropertiesProps) {
}
</div>
</div>
{!isCloud && (
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
<aria.Heading
level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
>
{getText('metadata')}
</aria.Heading>
<table>
<tbody>
<tr data-testid="asset-panel-permissions" className="h-row">
<td className="text my-auto min-w-side-panel-label p-0">
<aria.Label className="text inline-block">{getText('path')}</aria.Label>
</td>
<td className="w-full p-0">
<div className="flex gap-2">
<span className="grow">{path}</span>
<ariaComponents.CopyButton copyText={path ?? ''} />
</div>
</td>
</tr>
</tbody>
</table>
</div>
)}
{isCloud && (
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
<aria.Heading
@ -221,11 +253,26 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</aria.Heading>
<table>
<tbody>
{path != null && (
<tr data-testid="asset-panel-permissions" className="h-row">
<td className="text my-auto min-w-side-panel-label p-0">
<aria.Label className="text inline-block">{getText('path')}</aria.Label>
</td>
<td className="w-full p-0">
<div className="flex items-center gap-2">
<ariaComponents.Text className="w-0 grow" truncate="1">
{decodeURI(path)}
</ariaComponents.Text>
<ariaComponents.CopyButton copyText={path} />
</div>
</td>
</tr>
)}
<tr data-testid="asset-panel-permissions" className="h-row">
<td className="text my-auto min-w-side-panel-label p">
<td className="text my-auto min-w-side-panel-label p-0">
<aria.Label className="text inline-block">{getText('sharedWith')}</aria.Label>
</td>
<td className="w-full p">
<td className="flex w-full gap-1 p-0">
<SharedWithColumn
isReadonly={isReadonly}
item={item}
@ -235,10 +282,10 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</td>
</tr>
<tr data-testid="asset-panel-labels" className="h-row">
<td className="text my-auto min-w-side-panel-label p">
<td className="text my-auto min-w-side-panel-label p-0">
<aria.Label className="text inline-block">{getText('labels')}</aria.Label>
</td>
<td className="w-full p">
<td className="flex w-full gap-1 p-0">
{item.item.labels?.map((value) => {
const label = labels.find((otherLabel) => otherLabel.value === value)
return label == null ? null : (
@ -254,8 +301,37 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</div>
)}
{isSecret && (
<div
ref={secretRef}
className="pointer-events-auto flex flex-col items-start gap-side-panel-section rounded-default"
{...secretSpotlight.props}
>
<aria.Heading
level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
>
{getText('secret')}
</aria.Heading>
<UpsertSecretModal
noDialog
canReset={false}
canCancel={false}
id={item.item.id}
name={item.item.title}
doCreate={async (name, value) => {
await updateSecretMutation.mutateAsync([item.item.id, { value }, name])
}}
/>
</div>
)}
{isDatalink && (
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
<div
ref={datalinkRef}
className="pointer-events-auto flex flex-col items-start gap-side-panel-section rounded-default"
{...datalinkSpotlight.props}
>
<aria.Heading
level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
@ -267,58 +343,41 @@ export default function AssetProperties(props: AssetPropertiesProps) {
<StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} />
</div>
: <>
<DatalinkInput
readOnly={!canEditThisAsset}
dropdownTitle="Type"
value={editedDatalinkValue}
onChange={setEditedDatalinkValue}
/>
{canEditThisAsset && (
<ariaComponents.ButtonGroup>
<ariaComponents.Button
size="medium"
variant="submit"
isDisabled={isDatalinkDisabled}
{...(isDatalinkDisabled ?
{ title: 'Edit the Datalink before updating it.' }
: {})}
onPress={() => {
void (async () => {
if (item.item.type === backendModule.AssetType.datalink) {
const oldDatalinkValue = datalinkValue
try {
setDatalinkValue(editedDatalinkValue)
await createDatalinkMutation.mutateAsync([
{
datalinkId: item.item.id,
name: item.item.title,
parentDirectoryId: null,
value: editedDatalinkValue,
},
])
} catch (error) {
toastAndLog(null, error)
setDatalinkValue(oldDatalinkValue)
setEditedDatalinkValue(oldDatalinkValue)
}
}
})()
}}
>
{getText('update')}
</ariaComponents.Button>
<ariaComponents.Button
size="medium"
variant="outline"
isDisabled={isDatalinkDisabled}
onPress={() => {
setEditedDatalinkValue(datalinkValue)
}}
>
{getText('cancel')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
)}
<ariaComponents.Form
schema={(z) =>
z.object({
value: z.unknown().refine(datalinkValidator.validateDatalink),
})
}
defaultValues={{ value: datalinkValue }}
className="w-full"
onSubmit={async ({ value }) => {
await createDatalinkMutation.mutateAsync([
{
datalinkId: item.item.id,
name: item.item.title,
parentDirectoryId: null,
value: value,
},
])
}}
>
{({ form }) => (
<>
<DatalinkFormInput
form={form}
name="value"
readOnly={!canEditThisAsset}
dropdownTitle={getText('type')}
/>
{canEditThisAsset && (
<ariaComponents.ButtonGroup>
<ariaComponents.Form.Submit action="update" />
</ariaComponents.ButtonGroup>
)}
</>
)}
</ariaComponents.Form>
</>
}
</div>

View File

@ -104,6 +104,7 @@ import {
createPlaceholderProjectAsset,
createRootDirectoryAsset,
createSpecialEmptyAsset,
createSpecialErrorAsset,
createSpecialLoadingAsset,
DatalinkId,
DirectoryId,
@ -153,7 +154,7 @@ import { EMPTY_SET, setPresence, withPresence } from '#/utilities/set'
import type { SortInfo } from '#/utilities/sorting'
import { SortDirection } from '#/utilities/sorting'
import { regexEscape } from '#/utilities/string'
import { twMerge } from '#/utilities/tailwindMerge'
import { twJoin, twMerge } from '#/utilities/tailwindMerge'
import { uniqueString } from '#/utilities/uniqueString'
import Visibility from '#/utilities/Visibility'
@ -469,10 +470,13 @@ export default function AssetsTable(props: AssetsTableProps) {
recentProjects: category.type === 'recent',
},
] as const,
queryFn: async ({ queryKey: [, , parentId, params] }) => ({
parentId,
children: await backend.listDirectory(params, parentId),
}),
queryFn: async ({ queryKey: [, , parentId, params] }) => {
try {
return { parentId, children: await backend.listDirectory(params, parentId) }
} catch {
throw Object.assign(new Error(), { parentId })
}
},
refetchInterval:
enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false,
@ -494,18 +498,30 @@ export default function AssetsTable(props: AssetsTableProps) {
],
),
combine: (results) => {
const rootQuery = results.find((directory) => directory.data?.parentId === rootDirectory.id)
const rootQuery = results.find(
(directory) =>
directory.data?.parentId === rootDirectory.id ||
// eslint-disable-next-line no-restricted-syntax
(directory.error as unknown as { parentId: string } | null)?.parentId ===
rootDirectory.id,
)
return {
rootDirectory: {
isFetching: rootQuery?.isFetching ?? true,
isLoading: rootQuery?.isLoading ?? true,
isError: rootQuery?.isError ?? false,
data: rootQuery?.data,
},
directories: new Map(
results.map((res) => [
res.data?.parentId,
{ isFetching: res.isFetching, isLoading: res.isLoading, data: res.data },
{
isFetching: res.isFetching,
isLoading: res.isLoading,
isError: res.isError,
data: res.data,
},
]),
),
}
@ -518,7 +534,7 @@ export default function AssetsTable(props: AssetsTableProps) {
type DirectoryQuery = typeof directories.rootDirectory.data
const rootDirectoryContent = directories.rootDirectory.data?.children
const isLoading = directories.rootDirectory.isLoading
const isLoading = directories.rootDirectory.isLoading && !directories.rootDirectory.isError
const assetTree = useMemo(() => {
const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath(user)
@ -535,6 +551,26 @@ export default function AssetsTable(props: AssetsTableProps) {
rootPath,
null,
)
} else if (directories.rootDirectory.isError) {
// eslint-disable-next-line no-restricted-syntax
return AssetTreeNode.fromAsset(
createRootDirectoryAsset(rootDirectoryId),
rootParentDirectoryId,
rootParentDirectoryId,
-1,
rootPath,
null,
).with({
children: [
AssetTreeNode.fromAsset(
createSpecialErrorAsset(rootDirectoryId),
rootDirectoryId,
rootDirectoryId,
0,
'',
),
],
})
}
const rootId = rootDirectory.id
@ -575,6 +611,18 @@ export default function AssetsTable(props: AssetsTableProps) {
),
],
})
} else if (childrenAssetsQuery.isError) {
node = node.with({
children: [
AssetTreeNode.fromAsset(
createSpecialErrorAsset(item.id),
item.id,
item.id,
depth,
'',
),
],
})
} else if (nestedChildren?.length === 0) {
node = node.with({
children: [
@ -626,10 +674,11 @@ export default function AssetsTable(props: AssetsTableProps) {
backend,
user,
rootDirectoryContent,
directories.rootDirectory.isError,
directories.directories,
rootDirectory,
rootParentDirectoryId,
rootDirectoryId,
directories.directories,
])
const filter = useMemo(() => {
@ -2745,23 +2794,27 @@ export default function AssetsTable(props: AssetsTableProps) {
{itemRows}
<tr className="hidden h-row first:table-row">
<td colSpan={columns.length} className="bg-transparent">
{category.type === 'trash' ?
<Text className="px-cell-x placeholder">
{query.query !== '' ?
<Text
className={twJoin(
'px-cell-x placeholder',
directories.rootDirectory.isError && 'text-danger',
)}
disableLineHeightCompensation
>
{directories.rootDirectory.isError ?
getText('thisFolderFailedToFetch')
: category.type === 'trash' ?
query.query !== '' ?
getText('noFilesMatchTheCurrentFilters')
: getText('yourTrashIsEmpty')}
</Text>
: category.type === 'recent' ?
<Text className="px-cell-x placeholder">
{query.query !== '' ?
: getText('yourTrashIsEmpty')
: category.type === 'recent' ?
query.query !== '' ?
getText('noFilesMatchTheCurrentFilters')
: getText('youHaveNoRecentProjects')}
</Text>
: query.query !== '' ?
<Text className="px-cell-x placeholder">
{getText('noFilesMatchTheCurrentFilters')}
</Text>
: <Text className="px-cell-x placeholder">{getText('youHaveNoFiles')}</Text>}
: getText('youHaveNoRecentProjects')
: query.query !== '' ?
getText('noFilesMatchTheCurrentFilters')
: getText('youHaveNoFiles')}
</Text>
</td>
</tr>
</tbody>

View File

@ -27,8 +27,10 @@ import UpsertSecretModal from '#/modals/UpsertSecretModal'
import {
useCanCreateAssets,
useCanDownload,
useDriveStore,
useIsAssetPanelVisible,
useSetIsAssetPanelPermanentlyVisible,
useSetIsAssetPanelTemporarilyVisible,
} from '#/providers/DriveProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { useSetModal } from '#/providers/ModalProvider'
@ -73,12 +75,14 @@ export default function DriveBar(props: DriveBarProps) {
false,
)
const driveStore = useDriveStore()
const { unsetModal } = useSetModal()
const { getText } = useText()
const inputBindings = useInputBindings()
const dispatchAssetEvent = useDispatchAssetEvent()
const canCreateAssets = useCanCreateAssets()
const isAssetPanelVisible = useIsAssetPanelVisible()
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const setIsAssetPanelPermanentlyVisible = useSetIsAssetPanelPermanentlyVisible()
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
@ -164,7 +168,14 @@ export default function DriveBar(props: DriveBarProps) {
icon={RightPanelIcon}
aria-label={isAssetPanelVisible ? getText('openAssetPanel') : getText('closeAssetPanel')}
onPress={() => {
setIsAssetPanelPermanentlyVisible(!isAssetPanelVisible)
const isAssetPanelTemporarilyVisible =
driveStore.getState().isAssetPanelTemporarilyVisible
if (isAssetPanelTemporarilyVisible) {
setIsAssetPanelTemporarilyVisible(false)
setIsAssetPanelPermanentlyVisible(false)
} else {
setIsAssetPanelPermanentlyVisible(!isAssetPanelVisible)
}
}}
/>
</div>

View File

@ -114,7 +114,7 @@ export function SetupTwoFaForm() {
<ButtonGroup>
<Form.Submit variant="delete">{getText('disable')}</Form.Submit>
<Form.Submit formnovalidate>{getText('cancel')}</Form.Submit>
<Form.Submit action="cancel" />
</ButtonGroup>
<Form.FormError />

View File

@ -304,7 +304,8 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.billingAndPlans,
icon: CreditCardIcon,
organizationOnly: true,
visible: ({ organization }) => organization?.subscription != null,
visible: ({ user, organization }) =>
user.isOrganizationAdmin && organization?.subscription != null,
sections: [],
onPress: (context) =>
context.queryClient

View File

@ -4,7 +4,7 @@ import * as React from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import BurgerMenuIcon from '#/assets/burger_menu.svg'
import { MenuTrigger } from '#/components/aria'
import { Heading, MenuTrigger } from '#/components/aria'
import { Button, Popover, Text } from '#/components/AriaComponents'
import { useStrictPortalContext } from '#/components/Portal'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
@ -185,7 +185,7 @@ export default function Settings() {
return (
<div className="flex flex-1 flex-col gap-4 overflow-hidden pl-page-x pt-4">
<Text.Heading level={1} className="flex items-center px-heading-x">
<Heading level={1} className="flex items-center px-heading-x">
<MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button size="custom" variant="custom" icon={BurgerMenuIcon} className="mr-3 sm:hidden" />
<Popover UNSTABLE_portalContainer={root}>
@ -214,7 +214,8 @@ export default function Settings() {
>
{data.organizationOnly === true ? organization?.name ?? 'your organization' : user.name}
</Text>
</Heading>
<div className="sm:ml-[14rem]">
<SearchBar
data-testid="settings-search-bar"
query={query}
@ -222,7 +223,7 @@ export default function Settings() {
label={getText('settingsSearchBarLabel')}
placeholder={getText('settingsSearchBarPlaceholder')}
/>
</Text.Heading>
</div>
<div className="flex sm:ml-[222px]" />
<div className="flex flex-1 gap-4 overflow-hidden">
<aside className="hidden h-full shrink-0 basis-[206px] flex-col overflow-y-auto overflow-x-hidden pb-12 sm:flex">

View File

@ -54,11 +54,13 @@ function useTabBarContext() {
// ==============
/** Props for a {@link TabBar}. */
export interface TabBarProps extends Readonly<React.PropsWithChildren> {}
export interface TabBarProps extends Readonly<React.PropsWithChildren> {
readonly className?: string
}
/** Switcher to choose the currently visible full-screen page. */
export default function TabBar(props: TabBarProps) {
const { children } = props
const { children, className } = props
const cleanupResizeObserverRef = React.useRef(() => {})
const backgroundRef = React.useRef<HTMLDivElement | null>(null)
const selectedTabRef = React.useRef<HTMLElement | null>(null)
@ -123,7 +125,7 @@ export default function TabBar(props: TabBarProps) {
return (
<FocusArea direction="horizontal">
{(innerProps) => (
<div className="relative flex grow" {...innerProps}>
<div className={tailwindMerge.twMerge('relative flex grow', className)} {...innerProps}>
<TabBarContext.Provider value={{ setSelectedTab }}>
<aria.TabList className="flex h-12 shrink-0 grow transition-[clip-path] duration-300">
<aria.Tab isDisabled>
@ -158,7 +160,7 @@ interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly project?: LaunchedProject
readonly isActive: boolean
readonly isHidden?: boolean
readonly icon: string
readonly icon: string | null
readonly labelId: text.TextId
readonly onClose?: () => void
readonly onLoadEnd?: () => void
@ -247,17 +249,17 @@ export function Tab(props: InternalTabProps) {
isHidden && 'hidden',
)}
>
{isLoading ?
<StatelessSpinner
state={spinnerModule.SpinnerState.loadingMedium}
size={16}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>
: <SvgMask
src={icon}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>
}
{icon != null &&
(isLoading ?
<StatelessSpinner
state={spinnerModule.SpinnerState.loadingMedium}
size={16}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>
: <SvgMask
src={icon}
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>)}
{data?.name ?? children}
{onClose && (
<div className="flex">

View File

@ -27,7 +27,11 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
const { getText } = useText()
return (
<Dialog title={getText('areYouSure')} modalProps={defaultOpen == null ? {} : { defaultOpen }}>
<Dialog
title={getText('areYouSure')}
role="alertdialog"
modalProps={defaultOpen == null ? {} : { defaultOpen }}
>
<Form schema={z.object({})} method="dialog" onSubmit={doDelete} onSubmitSuccess={unsetModal}>
<Text className="relative">{getText('confirmPrompt', actionText)}</Text>
@ -35,10 +39,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
<Form.Submit variant="delete" className="relative">
{actionButtonLabel}
</Form.Submit>
<Form.Submit formnovalidate variant="outline">
{getText('cancel')}
</Form.Submit>
<Form.Submit action="cancel" />
</ButtonGroup>
</Form>
</Dialog>

View File

@ -19,7 +19,7 @@ export default function ConfirmDeleteUserModal(props: ConfirmDeleteUserModalProp
const { getText } = useText()
return (
<Dialog title={getText('areYouSure')} className="items-center">
<Dialog title={getText('areYouSure')} role="alertdialog" className="items-center">
<Form
schema={z.object({})}
method="dialog"

View File

@ -1,105 +0,0 @@
/** @file Modal for editing the description of an asset. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import Modal from '#/components/Modal'
// =================================
// === EditAssetDescriptionModal ===
// =================================
/** Props for a {@link EditAssetDescriptionModal}. */
export interface EditAssetDescriptionModalProps {
readonly actionButtonLabel?: string
readonly initialDescription: string | null
/** Callback to change the asset's description. */
readonly doChangeDescription: (newDescription: string) => Promise<void>
}
/** Modal for editing the description of an asset. */
export default function EditAssetDescriptionModal(props: EditAssetDescriptionModalProps) {
const { getText } = textProvider.useText()
const {
doChangeDescription,
initialDescription,
actionButtonLabel = getText('editAssetDescriptionModalSubmit'),
} = props
const { unsetModal } = modalProvider.useSetModal()
const [description, setDescription] = React.useState(initialDescription ?? '')
const initialdescriptionRef = React.useRef(initialDescription)
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const { isPending, error, mutate } = reactQuery.useMutation({
mutationFn: doChangeDescription,
onSuccess: () => {
unsetModal()
},
})
React.useLayoutEffect(() => {
if (
textareaRef.current &&
typeof initialdescriptionRef.current === 'string' &&
initialdescriptionRef.current.length > 0
) {
textareaRef.current.select()
}
}, [])
return (
<Modal centered className="bg-dim">
<form
data-testid="edit-description-modal"
className="pointer-events-auto relative flex w-confirm-delete-modal flex-col gap-modal rounded-default p-modal-wide py-modal before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
onKeyDown={(event) => {
if (event.key !== 'Escape') {
event.stopPropagation()
}
}}
onClick={(event) => {
event.stopPropagation()
}}
onSubmit={(event) => {
event.preventDefault()
mutate(description)
}}
>
<div className="relative text-sm font-semibold">
{getText('editAssetDescriptionModalTitle')}
</div>
<textarea
ref={textareaRef}
className="relative h-16 resize-none rounded-default border-0.5 border-primary/20 px-4 py-2"
placeholder={getText('editAssetDescriptionModalPlaceholder')}
onChange={(event) => {
setDescription(event.target.value)
}}
value={description}
disabled={isPending}
autoFocus
/>
{error && <div className="relative text-sm text-red-500">{error.message}</div>}
<ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" type="submit" loading={isPending}>
{actionButtonLabel}
</ariaComponents.Button>
<ariaComponents.Button
variant="outline"
type="button"
onPress={unsetModal}
isDisabled={isPending}
>
{getText('editAssetDescriptionModalCancel')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
</form>
</Modal>
)
}

View File

@ -1,38 +1,22 @@
/** @file A modal to select labels for an asset. */
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Heading, Text } from '#/components/aria'
import { Button, ButtonGroup, Input } from '#/components/AriaComponents'
import { ButtonGroup, Checkbox, Form, Input } from '#/components/AriaComponents'
import ColorPicker from '#/components/ColorPicker'
import Label from '#/components/dashboard/Label'
import Modal from '#/components/Modal'
import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import {
findLeastUsedColor,
LabelName,
lChColorToCssColor,
type AnyAsset,
type LChColor,
} from '#/services/Backend'
import { submitForm } from '#/utilities/event'
import { merge } from '#/utilities/object'
import { findLeastUsedColor, LabelName, type AnyAsset, type LChColor } from '#/services/Backend'
import { regexEscape } from '#/utilities/string'
import { twMerge } from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
/** The maximum lightness at which a color is still considered dark. */
const MAXIMUM_DARK_LIGHTNESS = 50
// =========================
// === ManageLabelsModal ===
@ -42,7 +26,6 @@ const MAXIMUM_DARK_LIGHTNESS = 50
export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> {
readonly backend: Backend
readonly item: Asset
readonly setItem: Dispatch<SetStateAction<Asset>>
/** If this is `null`, this modal will be centered. */
readonly eventTarget: HTMLElement | null
}
@ -53,17 +36,49 @@ export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> {
export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
props: ManageLabelsModalProps<Asset>,
) {
const { backend, item, setItem, eventTarget } = props
const { backend, item, eventTarget } = props
const { unsetModal } = useSetModal()
const { getText } = useText()
const toastAndLog = useToastAndLog()
const { data: allLabels } = useBackendQuery(backend, 'listTags', [])
const [labels, setLabelsRaw] = useState(item.labels ?? [])
const [query, setQuery] = useState('')
const [color, setColor] = useState<LChColor | null>(null)
const leastUsedColor = useMemo(() => findLeastUsedColor(allLabels ?? []), [allLabels])
const position = useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const labelNames = useMemo(() => new Set(labels), [labels])
const createTagMutation = useMutation(backendMutationOptions(backend, 'createTag'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const form = Form.useForm({
schema: (z) =>
z.object({
labels: z.string().array().readonly(),
name: z.string(),
}),
defaultValues: { labels: item.labels ?? [], name: '' },
onSubmit: async ({ name }) => {
const labelName = LabelName(name)
try {
await createTagMutation.mutateAsync([{ value: labelName, color: color ?? leastUsedColor }])
await associateTagMutation.mutateAsync([
item.id,
[...(item.labels ?? []), labelName],
item.title,
])
unsetModal()
} catch (error) {
toastAndLog(null, error)
}
},
})
const formRef = useSyncRef(form)
useEffect(() => {
formRef.current.setValue('labels', item.labels ?? [])
}, [formRef, item.labels])
const query = Form.useWatch({ control: form.control, name: 'name' })
const labels = Form.useWatch({ control: form.control, name: 'labels' })
const regex = useMemo(() => new RegExp(regexEscape(query), 'i'), [query])
const canSelectColor = useMemo(
() => query !== '' && (allLabels ?? []).filter((label) => regex.test(label.value)).length === 0,
@ -71,54 +86,6 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
)
const canCreateNewLabel = canSelectColor
const createTag = useMutation(backendMutationOptions(backend, 'createTag')).mutateAsync
const associateTag = useMutation(backendMutationOptions(backend, 'associateTag')).mutateAsync
const setLabels = useCallback(
(valueOrUpdater: SetStateAction<readonly LabelName[]>) => {
setLabelsRaw(valueOrUpdater)
setItem((oldItem) =>
// This is SAFE, as the type of asset is not being changed.
// eslint-disable-next-line no-restricted-syntax
merge(oldItem, {
labels:
typeof valueOrUpdater !== 'function' ? valueOrUpdater : (
valueOrUpdater(oldItem.labels ?? [])
),
} as Partial<Asset>),
)
},
[setItem],
)
const doToggleLabel = async (name: LabelName) => {
const newLabels =
labelNames.has(name) ? labels.filter((label) => label !== name) : [...labels, name]
setLabels(newLabels)
try {
await associateTag([item.id, newLabels, item.title])
} catch (error) {
toastAndLog(null, error)
setLabels(labels)
}
}
const doSubmit = async () => {
unsetModal()
const labelName = LabelName(query)
setLabels((oldLabels) => [...oldLabels, labelName])
try {
await createTag([{ value: labelName, color: color ?? leastUsedColor }])
setLabels((newLabels) => {
void associateTag([item.id, newLabels, item.title])
return newLabels
})
} catch (error) {
toastAndLog(null, error)
setLabels((oldLabels) => oldLabels.filter((oldLabel) => oldLabel !== query))
}
}
return (
<Modal
centered={eventTarget == null}
@ -128,10 +95,7 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
tabIndex={-1}
style={
position != null ?
{
left: position.left + window.scrollX,
top: position.top + window.scrollY,
}
{ left: position.left + window.scrollX, top: position.top + window.scrollY }
: {}
}
className="sticky w-manage-labels-modal"
@ -144,87 +108,63 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
}}
>
<div className="absolute h-full w-full rounded-default bg-selected-frame backdrop-blur-default" />
<form
className="relative flex flex-col gap-modal rounded-default p-modal"
onSubmit={(event) => {
event.preventDefault()
void doSubmit()
}}
>
<Heading level={2} className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
<Form form={form} className="relative flex flex-col gap-modal rounded-default p-modal">
<Heading
slot="title"
level={2}
className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x"
>
<Text className="text text-sm font-bold">{getText('labels')}</Text>
</Heading>
{
<FocusArea direction="horizontal">
{(innerProps) => (
<ButtonGroup className="relative" {...innerProps}>
<FocusRing within>
<div
className={twMerge(
'flex grow items-center rounded-full border border-primary/10 px-input-x',
(
canSelectColor &&
color != null &&
color.lightness <= MAXIMUM_DARK_LIGHTNESS
) ?
'text-tag-text placeholder-tag-text'
: 'text-primary',
)}
style={
!canSelectColor || color == null ?
{}
: {
backgroundColor: lChColorToCssColor(color),
}
}
>
<Input
name="search-labels"
autoFocus
type="text"
size="custom"
placeholder={getText('labelSearchPlaceholder')}
className="text grow bg-transparent"
onChange={(event) => {
setQuery(event.currentTarget.value)
}}
/>
</div>
</FocusRing>
<Button variant="submit" isDisabled={!canCreateNewLabel} onPress={submitForm}>
{getText('create')}
</Button>
</ButtonGroup>
)}
</FocusArea>
}
{canSelectColor && (
<div className="mx-auto">
<ColorPicker setColor={setColor} />
</div>
)}
<FocusArea direction="vertical">
<FocusArea direction="horizontal">
{(innerProps) => (
<div className="max-h-manage-labels-list overflow-auto" {...innerProps}>
{(allLabels ?? [])
.filter((label) => regex.test(label.value))
.map((label) => (
<div key={label.id} className="flex h-row items-center">
<Label
active={labels.includes(label.value)}
color={label.color}
onPress={() => {
void doToggleLabel(label.value)
}}
>
{label.value}
</Label>
</div>
))}
</div>
<ButtonGroup className="relative" {...innerProps}>
<Input
form={form}
name="name"
autoFocus
type="text"
size="small"
placeholder={getText('labelSearchPlaceholder')}
/>
<Form.Submit isDisabled={!canCreateNewLabel}>{getText('create')}</Form.Submit>
</ButtonGroup>
)}
</FocusArea>
</form>
{canSelectColor && <ColorPicker setColor={setColor} className="w-full" />}
<FocusArea direction="vertical">
{(innerProps) => (
<Checkbox.Group
form={form}
name="labels"
className="max-h-manage-labels-list overflow-auto"
onChange={async (values) => {
await associateTagMutation.mutateAsync([
item.id,
values.map(LabelName),
item.title,
])
}}
{...innerProps}
>
<>
{(allLabels ?? [])
.filter((label) => regex.test(label.value))
.map((label) => {
const isActive = labels.includes(label.value)
return (
<Checkbox key={label.id} value={String(label.value)} isSelected={isActive}>
<Label active={isActive} color={label.color} onPress={() => {}}>
{label.value}
</Label>
</Checkbox>
)
})}
</>
</Checkbox.Group>
)}
</FocusArea>
</Form>
</div>
</Modal>
)

View File

@ -90,9 +90,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
</FocusArea>
<ButtonGroup className="relative">
<Form.Submit>{getText('create')}</Form.Submit>
<Form.Submit formnovalidate variant="outline">
{getText('cancel')}
</Form.Submit>
<Form.Submit action="cancel" />
</ButtonGroup>
<Form.FormError />
</>

View File

@ -29,7 +29,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
const { getText } = useText()
return (
<Dialog size="xlarge" title={getText('createDatalink')} isDismissable={false}>
<Dialog title={getText('createDatalink')} isDismissable={false}>
<Form
method="dialog"
schema={(z) =>
@ -54,7 +54,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
<ButtonGroup>
<Form.Submit>{getText('create')}</Form.Submit>
<Form.Submit formnovalidate>{getText('cancel')}</Form.Submit>
<Form.Submit action="cancel" />
</ButtonGroup>
<Form.FormError />

View File

@ -1,73 +1,107 @@
/** @file Modal for confirming delete of any type of asset. */
import { ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents'
import { ButtonGroup, Dialog, Form, INPUT_STYLES, Input } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import type { SecretId } from '#/services/Backend'
import { tv } from '#/utilities/tailwindVariants'
// =========================
// === UpsertSecretModal ===
// =========================
const CLASSIC_INPUT_STYLES = tv({
extend: INPUT_STYLES,
slots: {
base: '',
textArea: 'rounded-full border-0.5 border-primary/20 px-1.5',
inputContainer: 'before:h-0 after:h-0.5',
},
})
const CLASSIC_FIELD_STYLES = tv({
extend: Form.FIELD_STYLES,
slots: {
base: '',
label: 'px-2',
},
})
/** Props for a {@link UpsertSecretModal}. */
export interface UpsertSecretModalProps {
readonly noDialog?: boolean
readonly id: SecretId | null
readonly name: string | null
readonly defaultOpen?: boolean
readonly doCreate: (name: string, value: string) => Promise<void> | void
/** Defaults to `true`. */
readonly canCancel?: boolean
/** Defaults to `false`. */
readonly canReset?: boolean
}
/** A modal for creating and editing a secret. */
export default function UpsertSecretModal(props: UpsertSecretModalProps) {
const { id, name: nameRaw, defaultOpen, doCreate } = props
const { noDialog = false, id, name: nameRaw, defaultOpen, doCreate } = props
const { canCancel = true, canReset = false } = props
const { getText } = useText()
const isCreatingSecret = id == null
const isNameEditable = nameRaw == null
return (
<Dialog
title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}
modalProps={defaultOpen == null ? {} : { defaultOpen }}
isDismissable={false}
>
<Form
testId="upsert-secret-modal"
method="dialog"
schema={(z) =>
z.object({ name: z.string().min(1, getText('emptyStringError')), value: z.string() })
const form = Form.useForm({
method: 'dialog',
schema: (z) =>
z.object({ name: z.string().min(1, getText('emptyStringError')), value: z.string() }),
defaultValues: { name: nameRaw ?? '', value: '' },
onSubmit: async ({ name, value }) => {
await doCreate(name, value)
},
})
const content = (
<Form form={form} testId="upsert-secret-modal" gap="none" className="w-full">
<Input
form={form}
name="name"
size="custom"
rounded="full"
autoFocus={isNameEditable}
autoComplete="off"
isDisabled={!isNameEditable}
label={getText('name')}
placeholder={getText('secretNamePlaceholder')}
variants={CLASSIC_INPUT_STYLES}
fieldVariants={CLASSIC_FIELD_STYLES}
/>
<Input
form={form}
name="value"
type="password"
size="custom"
rounded="full"
autoFocus={!isNameEditable}
autoComplete="off"
label={getText('value')}
placeholder={
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
}
defaultValues={{ name: nameRaw ?? '', value: '' }}
onSubmit={async ({ name, value }) => {
await doCreate(name, value)
}}
>
{({ form }) => (
<>
<Input
form={form}
name="name"
autoFocus
autoComplete="off"
disabled={!isNameEditable}
label={getText('name')}
placeholder={getText('secretNamePlaceholder')}
/>
<Password
form={form}
name="value"
autoFocus={!isNameEditable}
autoComplete="off"
label={getText('value')}
placeholder={
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
}
/>
<ButtonGroup>
<Form.Submit>{isCreatingSecret ? getText('create') : getText('update')}</Form.Submit>
<Form.Submit formnovalidate />
</ButtonGroup>
</>
)}
</Form>
</Dialog>
variants={CLASSIC_INPUT_STYLES}
fieldVariants={CLASSIC_FIELD_STYLES}
/>
<ButtonGroup className="mt-2">
<Form.Submit>{isCreatingSecret ? getText('create') : getText('update')}</Form.Submit>
{canCancel && <Form.Submit action="cancel" />}
{canReset && <Form.Reset action="cancel" />}
</ButtonGroup>
</Form>
)
return noDialog ? content : (
<Dialog
title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}
modalProps={defaultOpen == null ? {} : { defaultOpen }}
isDismissable={false}
>
{content}
</Dialog>
)
}

View File

@ -7,8 +7,8 @@ import * as z from 'zod'
import * as detect from 'enso-common/src/detect'
import DriveIcon from '#/assets/drive.svg'
import EditorIcon from '#/assets/network.svg'
import SettingsIcon from '#/assets/settings.svg'
import WorkspaceIcon from '#/assets/workspace.svg'
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import * as projectHooks from '#/hooks/projectHooks'
@ -311,7 +311,7 @@ function DashboardInner(props: DashboardProps) {
project={project}
key={project.id}
isActive={page === project.id}
icon={EditorIcon}
icon={WorkspaceIcon}
labelId="editorPageName"
onClose={() => {
closeProject(project)

View File

@ -99,8 +99,6 @@
--context-menu-macos-width: 14.375rem;
--context-menu-entry-padding-x: 0.75rem;
--separator-margin-y: 0.125rem;
/* The vertical gap between entries in a Datalink input. */
--json-schema-gap: 0.25rem;
--json-schema-text-input-width: 10rem;
--json-schema-object-input-padding: 0.5rem;
--json-schema-object-key-width: 11rem;

View File

@ -0,0 +1,65 @@
/** @file Utility functions to convert between units. */
// Taken from https://stackoverflow.com/a/75178110.
const DUMMY_RECT = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
const WIDTH = DUMMY_RECT.width.baseVal
const MODES = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'%': WIDTH.SVG_LENGTHTYPE_PERCENTAGE,
em: WIDTH.SVG_LENGTHTYPE_EMS,
ex: WIDTH.SVG_LENGTHTYPE_EXS,
px: WIDTH.SVG_LENGTHTYPE_PX,
cm: WIDTH.SVG_LENGTHTYPE_CM,
mm: WIDTH.SVG_LENGTHTYPE_MM,
in: WIDTH.SVG_LENGTHTYPE_IN,
pt: WIDTH.SVG_LENGTHTYPE_PT,
pc: WIDTH.SVG_LENGTHTYPE_PC,
}
/** CSS units supported by {@link convertCSSUnits}. */
export type CSSUnit = keyof typeof MODES
/** Convert a CSS length measurement from one unit to another. */
export function convertCSSUnits(
value: number,
from: CSSUnit,
to: CSSUnit,
parent?: HTMLElement | SVGElement,
) {
if (parent) {
parent.appendChild(DUMMY_RECT)
}
WIDTH.newValueSpecifiedUnits(MODES[from], value)
WIDTH.convertToSpecifiedUnits(MODES[to])
const out = { number: WIDTH.valueInSpecifiedUnits, string: WIDTH.valueAsString }
if (parent) {
parent.removeChild(DUMMY_RECT)
}
return out
}
/** Convert a CSS length measurement from a CSS string value to a number in the given unit. */
export function convertCSSUnitString(
value: string,
to: CSSUnit,
parent?: HTMLElement | SVGElement,
) {
const match = value.match(/^([\d.]+)(%|rem|em|ex|px|cm|mm|in|pt|pc)$/)
if (match) {
const [, numericValueAsString = '', from = ''] = match
const numericValue = Number(numericValueAsString)
switch (from) {
case 'rem': {
// `rem` is just `em` from the root.
return convertCSSUnits(numericValue, 'em', to)
}
default: {
// This is SAFE, as the regex ensures that the only valid values are CSS units.
// eslint-disable-next-line no-restricted-syntax
return convertCSSUnits(numericValue, from as CSSUnit, to, parent)
}
}
} else {
return { number: 0, string: `0${to}` }
}
}

View File

@ -226,7 +226,6 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
'modifiers-macos': 'var(--modifiers-macos-gap)',
'side-panel': 'var(--side-panel-gap)',
'side-panel-section': 'var(--side-panel-section-gap)',
'json-schema': 'var(--json-schema-gap)',
'asset-search-bar': 'var(--asset-search-bar-gap)',
'drive-bar': 'var(--drive-bar-gap)',
'column-items': 'var(--column-items-gap)',
@ -343,7 +342,8 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
},
zIndex: {
1: '1',
tooltip: '2',
spotlight: '2',
tooltip: '3',
},
backdropBlur: {
xs: '2px',

View File

@ -38,6 +38,11 @@ export const LoadingAssetId = newtype.newtypeConstructor<LoadingAssetId>()
export type EmptyAssetId = newtype.Newtype<string, 'EmptyAssetId'>
export const EmptyAssetId = newtype.newtypeConstructor<EmptyAssetId>()
/** Unique identifier for an asset representing the nonexistent children of a directory
* that failed to fetch. */
export type ErrorAssetId = newtype.Newtype<string, 'ErrorAssetId'>
export const ErrorAssetId = newtype.newtypeConstructor<ErrorAssetId>()
/** Unique identifier for a user's project. */
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
export const ProjectId = newtype.newtypeConstructor<ProjectId>()
@ -710,8 +715,10 @@ export enum AssetType {
/** A special {@link AssetType} representing the unknown items of a directory, before the
* request to retrieve the items completes. */
specialLoading = 'specialLoading',
/** A special {@link AssetType} representing the sole child of an empty directory. */
/** A special {@link AssetType} representing a directory listing that is empty. */
specialEmpty = 'specialEmpty',
/** A special {@link AssetType} representing a directory listing that errored. */
specialError = 'specialError',
}
/** The corresponding ID newtype for each {@link AssetType}. */
@ -723,12 +730,13 @@ export interface IdType {
readonly [AssetType.directory]: DirectoryId
readonly [AssetType.specialLoading]: LoadingAssetId
readonly [AssetType.specialEmpty]: EmptyAssetId
readonly [AssetType.specialError]: ErrorAssetId
}
/** Integers (starting from 0) corresponding to the order in which each asset type should appear
* in a directory listing. */
export const ASSET_TYPE_ORDER: Readonly<Record<AssetType, number>> = {
// This is a sequence of numbers, not magic numbers. `999` and `1000` are arbitrary numbers
// This is a sequence of numbers, not magic numbers. `1000` is an arbitrary number
// that are higher than the number of possible asset types.
/* eslint-disable @typescript-eslint/no-magic-numbers */
[AssetType.directory]: 0,
@ -736,8 +744,9 @@ export const ASSET_TYPE_ORDER: Readonly<Record<AssetType, number>> = {
[AssetType.file]: 2,
[AssetType.datalink]: 3,
[AssetType.secret]: 4,
[AssetType.specialLoading]: 999,
[AssetType.specialLoading]: 1000,
[AssetType.specialEmpty]: 1000,
[AssetType.specialError]: 1000,
/* eslint-enable @typescript-eslint/no-magic-numbers */
}
@ -788,6 +797,9 @@ export interface SpecialLoadingAsset extends Asset<AssetType.specialLoading> {}
/** A convenience alias for {@link Asset}<{@link AssetType.specialEmpty}>. */
export interface SpecialEmptyAsset extends Asset<AssetType.specialEmpty> {}
/** A convenience alias for {@link Asset}<{@link AssetType.specialError}>. */
export interface SpecialErrorAsset extends Asset<AssetType.specialError> {}
/** Creates a {@link DirectoryAsset} representing the root directory for the organization,
* with all irrelevant fields initialized to default values. */
export function createRootDirectoryAsset(directoryId: DirectoryId): DirectoryAsset {
@ -881,6 +893,22 @@ export function createSpecialEmptyAsset(directoryId: DirectoryId): SpecialEmptyA
}
}
/** Creates a {@link SpecialErrorAsset}, with all irrelevant fields initialized to default
* values. */
export function createSpecialErrorAsset(directoryId: DirectoryId): SpecialErrorAsset {
return {
type: AssetType.specialError,
title: '',
id: ErrorAssetId(uniqueString.uniqueString()),
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId,
permissions: [],
projectState: null,
labels: [],
description: null,
}
}
/** Any object with a `type` field matching the given `AssetType`. */
interface HasType<Type extends AssetType> {
readonly type: Type
@ -894,6 +922,7 @@ export type AnyAsset<Type extends AssetType = AssetType> = Extract<
| ProjectAsset
| SecretAsset
| SpecialEmptyAsset
| SpecialErrorAsset
| SpecialLoadingAsset,
HasType<Type>
>
@ -941,6 +970,10 @@ export function createPlaceholderAssetId<Type extends AssetType>(
result = EmptyAssetId(id)
break
}
case AssetType.specialError: {
result = ErrorAssetId(id)
break
}
}
// This is SAFE, just too dynamic for TypeScript to correctly typecheck.
// eslint-disable-next-line no-restricted-syntax

View File

@ -177,6 +177,7 @@
"secretAssetType": "secret",
"specialLoadingAssetType": "special loading asset",
"specialEmptyAssetType": "special empty asset",
"specialErrorAssetType": "special error asset",
"couldNotConnectToPM": "Could not connect to the Project Manager. Please try restarting Enso, or manually launching the Project Manager.",
"upgradeToUseCloud": "Upgrade your plan to use Enso Cloud.",
@ -258,6 +259,7 @@
"pendingInvitation": "Pending Invitation",
"versions": "Versions",
"datalink": "Datalink",
"secret": "Secret",
"createDatalink": "Create Datalink",
"resetAll": "Reset All",
"openHelpChat": "Open Help Chat",
@ -381,6 +383,7 @@
"downloadFreeEditionMessage": "Alternatively, you can download the free edition to start working on your local projects.",
"downloadFreeEdition": "Download Free Edition",
"thisFolderIsEmpty": "This folder is empty.",
"thisFolderFailedToFetch": "This folder failed to fetch.",
"yourTrashIsEmpty": "Your trash is empty.",
"deleteTheAssetTypeTitle": "delete the $0 '$1'",
"trashTheAssetTypeTitle": "move the $0 '$1' to Trash",
@ -477,6 +480,7 @@
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at",
"youHaveNoUserGroupsAdmin": "This organization has no user groups. You can create one using the button above.",
"youHaveNoUserGroupsNonAdmin": "This organization has no user groups. You can create one using the button above.",
"xIsUsingTheProject": "'$0' is currently using the project",
"enableMultitabs": "Enable Multi-Tabs",
"enableMultitabsDescription": "Open multiple projects at the same time.",

View File

@ -138,6 +138,7 @@ interface PlaceholderOverrides {
readonly organizationNameSettingsInputDescription: [howLong: number]
readonly trialDescription: [days: number]
readonly groupNameSettingsInputDescription: [howLong: number]
readonly xIsUsingTheProject: [userName: string]
readonly arbitraryFieldTooLarge: [maxSize: string]
readonly arbitraryFieldTooSmall: [minSize: string]

View File

@ -61,6 +61,9 @@ importers:
'@monaco-editor/react':
specifier: 4.6.0
version: 4.6.0(monaco-editor@0.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/interactions':
specifier: ^3.22.3
version: 3.22.3(react@18.3.1)
'@sentry/react':
specifier: ^7.74.0
version: 7.118.0(react@18.3.1)
@ -2241,8 +2244,8 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-aria/interactions@3.22.2':
resolution: {integrity: sha512-xE/77fRVSlqHp2sfkrMeNLrqf2amF/RyuAS6T5oDJemRSgYM3UoxTbWjucPhfnoW7r32pFPHHgz4lbdX8xqD/g==}
'@react-aria/interactions@3.22.3':
resolution: {integrity: sha512-RRUb/aG+P0IKTIWikY/SylB6bIbLZeztnZY2vbe7RAG5MgVaCgn5HQ45SI15GlTmhsFG8CnF6slJsUFJiNHpbQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
@ -2337,6 +2340,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-aria/ssr@3.9.6':
resolution: {integrity: sha512-iLo82l82ilMiVGy342SELjshuWottlb5+VefO3jOQqQRNYnJBFpUSadswDPbRimSgJUZuFwIEYs6AabkP038fA==}
engines: {node: '>= 12'}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-aria/switch@3.6.7':
resolution: {integrity: sha512-yBNvKylhc3ZRQ0+7mD0mIenRRe+1yb8YaqMMZr8r3Bf87LaiFtQyhRFziq6ZitcwTJz5LEWjBihxbSVvUrf49w==}
peerDependencies:
@ -2391,6 +2400,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-aria/utils@3.25.3':
resolution: {integrity: sha512-PR5H/2vaD8fSq0H/UB9inNbc8KDcVmW6fYAfSWkkn+OAdhTTMVKqXXrZuZBWyFfSD5Ze7VN6acr4hrOQm2bmrA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-aria/virtualizer@4.0.2':
resolution: {integrity: sha512-HNhpZl53UM2Z8g0DNvjAW7aZRwOReYgKRxdTF/IlYHNMLpdqWZinKwLbxZCsbgX3SCjdIGns90YhkMSKVpfrpw==}
peerDependencies:
@ -2535,6 +2549,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-stately/utils@3.10.4':
resolution: {integrity: sha512-gBEQEIMRh5f60KCm7QKQ2WfvhB2gLUr9b72sqUdIZ2EG+xuPgaIlCBeSicvjmjBvYZwOjoOEnmIkcx2GHp/HWw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-stately/virtualizer@4.0.2':
resolution: {integrity: sha512-LiSr6E6OoL/cKVFO088zEzkNGj41g02nlOAgLluYONncNEjoYiHmb8Yw0otPgViVLKiFjO6Kk4W+dbt8EZ51Ag==}
peerDependencies:
@ -2645,6 +2664,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-types/shared@3.25.0':
resolution: {integrity: sha512-OZSyhzU6vTdW3eV/mz5i6hQwQUhkRs7xwY2d1aqPvTdMe0+2cY7Fwp45PAiwYLEj73i9ro2FxF9qC4DvHGSCgQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0
'@react-types/slider@3.7.5':
resolution: {integrity: sha512-bRitwQRQjQoOcKEdPMljnvm474dwrmsc6pdsVQDh/qynzr+KO9IHuYc3qPW53WVE2hMQJDohlqtCAWQXWQ5Vcg==}
peerDependencies:
@ -9181,7 +9205,7 @@ snapshots:
'@react-aria/button@3.9.8(react@18.3.1)':
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/toggle': 3.7.7(react@18.3.1)
'@react-types/button': 3.9.6(react@18.3.1)
@ -9193,7 +9217,7 @@ snapshots:
dependencies:
'@internationalized/date': 3.5.5
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/live-announcer': 3.3.4
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/calendar': 3.5.4(react@18.3.1)
@ -9207,7 +9231,7 @@ snapshots:
'@react-aria/checkbox@3.14.6(react@18.3.1)':
dependencies:
'@react-aria/form': 3.0.8(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/toggle': 3.10.7(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9232,7 +9256,7 @@ snapshots:
'@react-aria/color@3.0.0-rc.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/numberfield': 3.11.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/slider': 3.7.11(react@18.3.1)
'@react-aria/spinbutton': 3.6.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -9275,7 +9299,7 @@ snapshots:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/form': 3.0.8(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/spinbutton': 3.6.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9305,7 +9329,7 @@ snapshots:
dependencies:
'@internationalized/string': 3.2.3
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/live-announcer': 3.3.4
'@react-aria/overlays': 3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9318,7 +9342,7 @@ snapshots:
'@react-aria/focus@3.18.2(react@18.3.1)':
dependencies:
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
'@swc/helpers': 0.5.12
@ -9327,7 +9351,7 @@ snapshots:
'@react-aria/form@3.0.8(react@18.3.1)':
dependencies:
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/form': 3.0.5(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
@ -9338,7 +9362,7 @@ snapshots:
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/live-announcer': 3.3.4
'@react-aria/selection': 3.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9357,7 +9381,7 @@ snapshots:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/grid': 3.10.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/selection': 3.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/collections': 3.10.9(react@18.3.1)
@ -9380,11 +9404,11 @@ snapshots:
'@swc/helpers': 0.5.12
react: 18.3.1
'@react-aria/interactions@3.22.2(react@18.3.1)':
'@react-aria/interactions@3.22.3(react@18.3.1)':
dependencies:
'@react-aria/ssr': 3.9.5(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
'@react-aria/ssr': 3.9.6(react@18.3.1)
'@react-aria/utils': 3.25.3(react@18.3.1)
'@react-types/shared': 3.25.0(react@18.3.1)
'@swc/helpers': 0.5.12
react: 18.3.1
@ -9398,7 +9422,7 @@ snapshots:
'@react-aria/link@3.7.4(react@18.3.1)':
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-types/link': 3.5.7(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
@ -9407,7 +9431,7 @@ snapshots:
'@react-aria/listbox@3.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/selection': 3.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9427,7 +9451,7 @@ snapshots:
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/overlays': 3.23.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/selection': 3.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9452,7 +9476,7 @@ snapshots:
'@react-aria/numberfield@3.11.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/spinbutton': 3.6.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/textfield': 3.14.8(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9469,7 +9493,7 @@ snapshots:
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/ssr': 3.9.5(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-aria/visually-hidden': 3.8.15(react@18.3.1)
@ -9496,7 +9520,7 @@ snapshots:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/form': 3.0.8(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/radio': 3.10.7(react@18.3.1)
@ -9521,7 +9545,7 @@ snapshots:
dependencies:
'@react-aria/form': 3.0.8(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/listbox': 3.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/menu': 3.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -9540,7 +9564,7 @@ snapshots:
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/selection': 3.16.2(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
@ -9559,7 +9583,7 @@ snapshots:
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/slider': 3.5.7(react@18.3.1)
@ -9584,6 +9608,11 @@ snapshots:
'@swc/helpers': 0.5.12
react: 18.3.1
'@react-aria/ssr@3.9.6(react@18.3.1)':
dependencies:
'@swc/helpers': 0.5.12
react: 18.3.1
'@react-aria/switch@3.6.7(react@18.3.1)':
dependencies:
'@react-aria/toggle': 3.10.7(react@18.3.1)
@ -9598,7 +9627,7 @@ snapshots:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/grid': 3.10.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/live-announcer': 3.3.4
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-aria/visually-hidden': 3.8.15(react@18.3.1)
@ -9630,7 +9659,7 @@ snapshots:
dependencies:
'@react-aria/gridlist': 3.9.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/selection': 3.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -9657,7 +9686,7 @@ snapshots:
'@react-aria/toggle@3.10.7(react@18.3.1)':
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/toggle': 3.7.7(react@18.3.1)
'@react-types/checkbox': 3.8.3(react@18.3.1)
@ -9677,7 +9706,7 @@ snapshots:
'@react-aria/tooltip@3.7.7(react@18.3.1)':
dependencies:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/tooltip': 3.4.12(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
@ -9707,10 +9736,19 @@ snapshots:
clsx: 2.1.1
react: 18.3.1
'@react-aria/utils@3.25.3(react@18.3.1)':
dependencies:
'@react-aria/ssr': 3.9.6(react@18.3.1)
'@react-stately/utils': 3.10.4(react@18.3.1)
'@react-types/shared': 3.25.0(react@18.3.1)
'@swc/helpers': 0.5.12
clsx: 2.1.1
react: 18.3.1
'@react-aria/virtualizer@4.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-stately/virtualizer': 4.0.2(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
@ -9720,7 +9758,7 @@ snapshots:
'@react-aria/visually-hidden@3.8.15(react@18.3.1)':
dependencies:
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/utils': 3.25.2(react@18.3.1)
'@react-types/shared': 3.24.1(react@18.3.1)
'@swc/helpers': 0.5.12
@ -9956,6 +9994,11 @@ snapshots:
'@swc/helpers': 0.5.12
react: 18.3.1
'@react-stately/utils@3.10.4(react@18.3.1)':
dependencies:
'@swc/helpers': 0.5.12
react: 18.3.1
'@react-stately/virtualizer@4.0.2(react@18.3.1)':
dependencies:
'@react-aria/utils': 3.25.2(react@18.3.1)
@ -10076,6 +10119,10 @@ snapshots:
dependencies:
react: 18.3.1
'@react-types/shared@3.25.0(react@18.3.1)':
dependencies:
react: 18.3.1
'@react-types/slider@3.7.5(react@18.3.1)':
dependencies:
'@react-types/shared': 3.24.1(react@18.3.1)
@ -14414,7 +14461,7 @@ snapshots:
'@react-aria/color': 3.0.0-rc.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/dnd': 3.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/menu': 3.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/toolbar': 3.0.0-beta.8(react@18.3.1)
'@react-aria/tree': 3.0.0-alpha.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -14453,7 +14500,7 @@ snapshots:
'@react-aria/focus': 3.18.2(react@18.3.1)
'@react-aria/gridlist': 3.9.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/i18n': 3.12.2(react@18.3.1)
'@react-aria/interactions': 3.22.2(react@18.3.1)
'@react-aria/interactions': 3.22.3(react@18.3.1)
'@react-aria/label': 3.7.11(react@18.3.1)
'@react-aria/link': 3.7.4(react@18.3.1)
'@react-aria/listbox': 3.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)