mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 17:11:31 +03:00
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:
parent
80317dc950
commit
7a00e6ef26
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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('')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
/** @file Barrel file for the `ComboBox` component. */
|
||||
export * from './ComboBox'
|
@ -1,2 +1,2 @@
|
||||
/** @file Barrel file for the DatePicker component. */
|
||||
/** @file Barrel file for the `DatePicker` component. */
|
||||
export * from './DatePicker'
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -4,6 +4,7 @@
|
||||
* Barrel export file for Inputs
|
||||
*/
|
||||
|
||||
export * from './ComboBox'
|
||||
export * from './DatePicker'
|
||||
export * from './Dropdown'
|
||||
export * from './Input'
|
||||
|
@ -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) => (
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
||||
// ==================
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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` }
|
||||
|
@ -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
|
||||
|
@ -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. */
|
||||
|
@ -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}
|
||||
|
@ -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'
|
||||
|
@ -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}
|
||||
/>,
|
||||
)
|
||||
|
@ -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 <></>
|
||||
}
|
||||
|
@ -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] : []))
|
||||
|
@ -133,6 +133,7 @@ const INVALIDATION_MAP: Partial<
|
||||
changeUserGroup: ['listUsers'],
|
||||
createTag: ['listTags'],
|
||||
deleteTag: ['listTags'],
|
||||
associateTag: ['listDirectory'],
|
||||
acceptInvitation: [INVALIDATE_ALL_QUERIES],
|
||||
declineInvitation: ['usersMe'],
|
||||
createProject: ['listDirectory'],
|
||||
|
135
app/dashboard/src/hooks/dimensionsHooks.ts
Normal file
135
app/dashboard/src/hooks/dimensionsHooks.ts
Normal 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]
|
||||
}
|
114
app/dashboard/src/hooks/spotlightHooks.tsx
Normal file
114
app/dashboard/src/hooks/spotlightHooks.tsx
Normal 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,
|
||||
)
|
||||
}
|
@ -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]) })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 />
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -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 />
|
||||
</>
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
65
app/dashboard/src/utilities/convertCSSUnits.ts
Normal file
65
app/dashboard/src/utilities/convertCSSUnits.ts
Normal 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}` }
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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.",
|
||||
|
@ -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]
|
||||
|
113
pnpm-lock.yaml
113
pnpm-lock.yaml
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user