Adjust Refresh Interval in Assets table (#10775)

This commit is contained in:
Sergei Garin 2024-09-04 19:22:50 +03:00 committed by GitHub
parent 88aaa51341
commit 77183e50e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1682 additions and 1831 deletions

View File

@ -504,24 +504,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
(a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type],
)
const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets }
return json
})
await get(
remoteBackendPaths.LIST_FILES_PATH + '*',
() => ({ files: [] }) satisfies remoteBackend.ListFilesResponseBody,
)
await get(
remoteBackendPaths.LIST_PROJECTS_PATH + '*',
() => ({ projects: [] }) satisfies remoteBackend.ListProjectsResponseBody,
)
await get(
remoteBackendPaths.LIST_SECRETS_PATH + '*',
() => ({ secrets: [] }) satisfies remoteBackend.ListSecretsResponseBody,
)
await get(
remoteBackendPaths.LIST_TAGS_PATH + '*',
() => ({ tags: labels }) satisfies remoteBackend.ListTagsResponseBody,
)
await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => {
return { files: [] } satisfies remoteBackend.ListFilesResponseBody
})
await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => {
return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody
})
await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => {
return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody
})
await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => {
return { tags: labels } satisfies remoteBackend.ListTagsResponseBody
})
await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => {
if (currentUser != null) {
return { users } satisfies remoteBackend.ListUsersResponseBody
@ -584,6 +581,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
interface Body {
readonly parentDirectoryId: backend.DirectoryId
}
const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
// eslint-disable-next-line no-restricted-syntax
const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null
@ -605,7 +603,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const body: Body = await request.postDataJSON()
const parentId = body.parentDirectoryId
// Can be any asset ID.
const id = backend.DirectoryId(uniqueString.uniqueString())
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
const json: backend.CopyAssetResponse = {
asset: {
id,
@ -621,6 +619,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await route.fulfill({ json })
}
})
await get(remoteBackendPaths.INVITATION_PATH + '*', async (route) => {
await route.fulfill({
json: { invitations: [] } satisfies backend.ListInvitationsResponseBody,
@ -695,7 +694,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const searchParams: SearchParams = Object.fromEntries(
new URL(request.url()).searchParams.entries(),
) as never
const file = createFile(searchParams.file_name)
const file = addFile(searchParams.file_name)
return { path: '', id: file.id, project: null } satisfies backend.FileInfo
})
@ -703,7 +704,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: backend.CreateSecretRequestBody = await request.postDataJSON()
const secret = createSecret(body.name)
const secret = addSecret(body.name)
return secret.id
})
@ -721,6 +722,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
if (body.description != null) {
object.unsafeMutable(asset).description = body.description
}
if (body.parentDirectoryId != null) {
object.unsafeMutable(asset).parentId = body.parentDirectoryId
}
}
})
await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => {
@ -813,7 +818,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
currentUser = { ...currentUser, name: body.username }
}
})
await get(remoteBackendPaths.USERS_ME_PATH + '*', () => currentUser)
await get(remoteBackendPaths.USERS_ME_PATH + '*', () => {
return currentUser
})
await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment

View File

@ -23,7 +23,7 @@ test.test('copy', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@ -46,7 +46,7 @@ test.test('copy (keyboard)', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@ -69,7 +69,7 @@ test.test('move', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@ -88,7 +88,7 @@ test.test('move (drag)', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@ -129,7 +129,7 @@ test.test('move (keyboard)', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@ -164,11 +164,11 @@ test.test('duplicate', ({ page }) =>
.driveTable.rightClickRow(0)
.contextMenu.duplicate()
.driveTable.withRows(async (rows) => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
// Assets: [0: New Project 1, 1: New Project 1 (copy)]
await test.expect(rows).toHaveCount(2)
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}),
)
@ -184,7 +184,7 @@ test.test('duplicate (keyboard)', ({ page }) =>
.driveTable.withRows(async (rows) => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}),
)

View File

@ -33,7 +33,7 @@ test.test('drive view', ({ page }) =>
// user that project creation may take a while. Previously opened projects are stopped when the
// new project is created.
.driveTable.withRows(async (rows) => {
await actions.locateStopProjectButton(rows.nth(0)).click()
await actions.locateStopProjectButton(rows.nth(1)).click()
})
// Project context menu
.driveTable.rightClickRow(0)

View File

@ -49,7 +49,6 @@ import * as inputBindingsModule from '#/configurations/inputBindings'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
import DriveProvider from '#/providers/DriveProvider'
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
import { useHttpClient } from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
@ -91,10 +90,11 @@ import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as eventModule from '#/utilities/event'
import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
import { Path } from '#/utilities/path'
import { useInitAuthService } from '#/authentication/service'
import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
import { Path } from '#/utilities/path'
import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider'
// ============================
// === Global configuration ===
@ -492,7 +492,7 @@ function AppRouter(props: AppRouterProps) {
)
return (
<DevtoolsProvider>
<FeatureFlagsProvider>
<RouterProvider navigate={navigate}>
<SessionProvider
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
@ -517,7 +517,9 @@ function AppRouter(props: AppRouterProps) {
{routes}
{detect.IS_DEV_MODE && (
<suspense.Suspense>
<devtools.EnsoDevtools />
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
)}
</errorBoundary.ErrorBoundary>
@ -527,7 +529,7 @@ function AppRouter(props: AppRouterProps) {
</BackendProvider>
</SessionProvider>
</RouterProvider>
</DevtoolsProvider>
</FeatureFlagsProvider>
)
}

View File

@ -5,24 +5,39 @@ import * as modalProvider from '#/providers/ModalProvider'
import * as aria from '#/components/aria'
import type * as types from './types'
import { AnimatePresence, motion } from 'framer-motion'
const PLACEHOLDER = <div />
/**
* Props passed to the render function of a {@link DialogTrigger}.
*/
export interface DialogTriggerRenderProps {
readonly isOpened: boolean
}
/**
* Props for a {@link DialogTrigger}.
*/
export interface DialogTriggerProps extends types.DialogTriggerProps {}
export interface DialogTriggerProps extends Omit<aria.DialogTriggerProps, 'children'> {
/**
* The trigger element.
*/
readonly children: [
React.ReactElement,
React.ReactElement | ((props: DialogTriggerRenderProps) => React.ReactElement),
]
}
/** A DialogTrigger opens a dialog when a trigger element is pressed. */
export function DialogTrigger(props: DialogTriggerProps) {
const { children, onOpenChange, ...triggerProps } = props
const [isOpened, setIsOpened] = React.useState(false)
const { setModal, unsetModal } = modalProvider.useSetModal()
const onOpenChangeInternal = React.useCallback(
(isOpened: boolean) => {
if (isOpened) {
(opened: boolean) => {
if (opened) {
// We're using a placeholder here just to let the rest of the code know that the modal
// is open.
setModal(PLACEHOLDER)
@ -30,14 +45,36 @@ export function DialogTrigger(props: DialogTriggerProps) {
unsetModal()
}
onOpenChange?.(isOpened)
setIsOpened(opened)
onOpenChange?.(opened)
},
[setModal, unsetModal, onOpenChange],
)
const renderProps = {
isOpened,
} satisfies DialogTriggerRenderProps
const [trigger, dialog] = children
return (
<aria.DialogTrigger onOpenChange={onOpenChangeInternal} {...triggerProps}>
{children}
{trigger}
{/* We're using AnimatePresence here to animate the dialog in and out. */}
<AnimatePresence>
{isOpened && (
<motion.div
style={{ display: 'none' }}
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
>
{typeof dialog === 'function' ? dialog(renderProps) : dialog}
</motion.div>
)}
</AnimatePresence>
</aria.DialogTrigger>
)
}

View File

@ -10,6 +10,3 @@ export interface DialogProps extends aria.DialogProps {
readonly modalProps?: Pick<aria.ModalOverlayProps, 'className' | 'defaultOpen' | 'isOpen'>
readonly testId?: string
}
/** The props for the DialogTrigger component. */
export interface DialogTriggerProps extends aria.DialogTriggerProps {}

View File

@ -14,23 +14,11 @@ import * as aria from '#/components/aria'
import * as errorUtils from '#/utilities/error'
import { forwardRef } from '#/utilities/react'
import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as dialog from '../Dialog'
import * as components from './components'
import * as styles from './styles'
import type * as types from './types'
/**
* Maps the value to the event object.
*/
function mapValueOnEvent(value: unknown) {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return value
} else {
return { target: { value } }
}
}
/** Form component. It wraps a `form` and provides form context.
* It also handles form submission.
* Provides better error handling and form state management and better UX out of the box. */
@ -71,19 +59,12 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
formOptions.defaultValues = defaultValues
}
const innerForm = components.useForm(
form ?? {
shouldFocusError: true,
schema,
...formOptions,
},
defaultValues,
)
const dialogContext = dialog.useDialogContext()
const innerForm = components.useForm(form ?? { shouldFocusError: true, schema, ...formOptions })
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
const dialogContext = dialog.useDialogContext()
const formMutation = reactQuery.useMutation({
// We use template literals to make the mutation key more readable in the devtools
// This mutation exists only for debug purposes - React Query dev tools record the mutation,
@ -140,51 +121,13 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
{ isDisabled: canSubmitOffline },
)
const {
formState,
clearErrors,
getValues,
setValue,
setError,
register,
unregister,
setFocus,
reset,
control,
} = innerForm
const formStateRenderProps: types.FormStateRenderProps<Schema> = {
formState,
register: (name, options) => {
const registered = register(name, options)
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
...registered,
isDisabled: registered.disabled ?? false,
isRequired: registered.required ?? false,
isInvalid: Boolean(formState.errors[name]),
onChange: (value) => registered.onChange(mapValueOnEvent(value)),
onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
}
return result
},
unregister,
setError,
clearErrors,
getValues,
setValue,
setFocus,
reset,
control,
form: innerForm,
}
const base = styles.FORM_STYLES({
className: typeof className === 'function' ? className(formStateRenderProps) : className,
className: typeof className === 'function' ? className(innerForm) : className,
gap,
})
const { formState, setError } = innerForm
// eslint-disable-next-line no-restricted-syntax
const errors = Object.fromEntries(
Object.entries(formState.errors).map(([key, error]) => {
@ -208,35 +151,34 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
}
}}
className={base}
style={typeof style === 'function' ? style(formStateRenderProps) : style}
style={typeof style === 'function' ? style(innerForm) : style}
noValidate
data-testid={testId}
{...formProps}
>
<aria.FormValidationContext.Provider value={errors}>
<reactHookForm.FormProvider {...innerForm}>
{typeof children === 'function' ? children(formStateRenderProps) : children}
{typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children}
</reactHookForm.FormProvider>
</aria.FormValidationContext.Provider>
</form>
)
}) as unknown as Mutable<
Pick<
typeof components,
| 'FIELD_STYLES'
| 'Field'
| 'FormError'
| 'Reset'
| 'schema'
| 'Submit'
| 'useField'
| 'useForm'
| 'useFormSchema'
>
> &
(<Schema extends components.TSchema>(
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>,
) => React.JSX.Element)
}) as unknown as (<Schema extends components.TSchema>(
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>,
) => React.JSX.Element) & {
/* eslint-disable @typescript-eslint/naming-convention */
schema: typeof components.schema
useForm: typeof components.useForm
useField: typeof components.useField
Submit: typeof components.Submit
Reset: typeof components.Reset
Field: typeof components.Field
FormError: typeof components.FormError
useFormSchema: typeof components.useFormSchema
Controller: typeof components.Controller
FIELD_STYLES: typeof components.FIELD_STYLES
/* eslint-enable @typescript-eslint/naming-convention */
}
Form.schema = components.schema
Form.useForm = components.useForm
@ -246,4 +188,5 @@ Form.Submit = components.Submit
Form.Reset = components.Reset
Form.FormError = components.FormError
Form.Field = components.Field
Form.Controller = components.Controller
Form.FIELD_STYLES = components.FIELD_STYLES

View File

@ -23,7 +23,7 @@ export interface FieldComponentProps<Schema extends types.TSchema>
types.FieldProps {
readonly 'data-testid'?: string | undefined
readonly name: Path<types.FieldValues<Schema>>
readonly form?: types.FormInstance<Schema>
readonly form?: types.FormInstance<Schema> | undefined
readonly isInvalid?: boolean | undefined
readonly className?: string | undefined
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)

View File

@ -3,6 +3,7 @@
*
* Barrel file for form components.
*/
export { Controller } from 'react-hook-form'
export * from './Field'
export * from './FormError'
export * from './Reset'

View File

@ -42,12 +42,41 @@ export interface UseFormProps<Schema extends TSchema>
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema)
}
/**
* Register function for a form field.
*/
export type UseFormRegister<Schema extends TSchema> = <
TFieldName extends FieldPath<Schema> = FieldPath<Schema>,
>(
name: TFieldName,
options?: reactHookForm.RegisterOptions<FieldValues<Schema>, TFieldName>,
// eslint-disable-next-line no-restricted-syntax
) => UseFormRegisterReturn<Schema, TFieldName>
/**
* UseFormRegister return type.
*/
export interface UseFormRegisterReturn<
Schema extends TSchema,
TFieldName extends FieldPath<Schema> = FieldPath<Schema>,
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onBlur' | 'onChange'> {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onChange: <Value>(value: Value) => Promise<boolean | void>
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onBlur: <Value>(value: Value) => Promise<boolean | void>
readonly isDisabled?: boolean
readonly isRequired?: boolean
readonly isInvalid?: boolean
}
/**
* Return type of the useForm hook.
* @alias reactHookForm.UseFormReturn
*/
export interface UseFormReturn<Schema extends TSchema>
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> {}
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> {
readonly register: UseFormRegister<Schema>
}
/**
* Form state type.

View File

@ -39,7 +39,6 @@ export function useField<
const { field, fieldState, formState } = reactHookForm.useController({
name,
control: formInstance.control,
disabled: isDisabled,
...(defaultValue != null ? { defaultValue } : {}),
})

View File

@ -12,12 +12,24 @@ import invariant from 'tiny-invariant'
import * as schemaModule from './schema'
import type * as types from './types'
/**
* Maps the value to the event object.
*/
function mapValueOnEvent(value: unknown) {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return value
} else {
return { target: { value } }
}
}
/**
* A hook that returns a form instance.
* @param optionsOrFormInstance - Either form options or a form instance
*
* If form instance is passed, it will be returned as is
* If form options are passed, a form instance will be created and returned
* If form instance is passed, it will be returned as is.
*
* If form options are passed, a form instance will be created and returned.
*
* ***Note:*** This hook accepts either a form instance(If form is created outside)
* or form options(and creates a form instance).
@ -28,9 +40,6 @@ import type * as types from './types'
*/
export function useForm<Schema extends types.TSchema>(
optionsOrFormInstance: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
defaultValues?:
| reactHookForm.DefaultValues<types.FieldValues<Schema>>
| ((payload?: unknown) => Promise<types.FieldValues<Schema>>),
): types.UseFormReturn<Schema> {
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
@ -44,38 +53,49 @@ export function useForm<Schema extends types.TSchema>(
`,
)
const form =
'formState' in optionsOrFormInstance ? optionsOrFormInstance : (
(() => {
const { schema, ...options } = optionsOrFormInstance
if ('formState' in optionsOrFormInstance) {
return optionsOrFormInstance
} else {
const { schema, ...options } = optionsOrFormInstance
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
return reactHookForm.useForm<
types.FieldValues<Schema>,
unknown,
types.TransformedValues<Schema>
>({
...options,
resolver: zodResolver.zodResolver(computedSchema, { async: true }),
})
})()
)
const formInstance = reactHookForm.useForm<
types.FieldValues<Schema>,
unknown,
types.TransformedValues<Schema>
>({
...options,
resolver: zodResolver.zodResolver(computedSchema),
})
const initialDefaultValues = React.useRef(defaultValues)
const register: types.UseFormRegister<Schema> = (name, opts) => {
const registered = formInstance.register(name, opts)
React.useEffect(() => {
// Expose default values to controlled inputs like `Selector` and `MultiSelector`.
// Using `defaultValues` is not sufficient as the value needs to be manually set at least once.
const defaults = initialDefaultValues.current
if (defaults) {
if (typeof defaults !== 'function') {
form.reset(defaults)
const onChange: types.UseFormRegisterReturn<Schema>['onChange'] = (value) =>
registered.onChange(mapValueOnEvent(value))
const onBlur: types.UseFormRegisterReturn<Schema>['onBlur'] = (value) =>
registered.onBlur(mapValueOnEvent(value))
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
...(registered.required != null ? { isRequired: registered.required } : {}),
isInvalid: !!formInstance.formState.errors[name],
onChange,
onBlur,
}
}
}, [form])
return form
return result
}
return {
...formInstance,
control: { ...formInstance.control, register },
register,
} satisfies types.UseFormReturn<Schema>
}
}
/**

View File

@ -42,11 +42,17 @@ interface BaseFormProps<Schema extends components.TSchema>
) => unknown
readonly style?:
| React.CSSProperties
| ((props: FormStateRenderProps<Schema>) => React.CSSProperties)
readonly children: React.ReactNode | ((props: FormStateRenderProps<Schema>) => React.ReactNode)
| ((props: components.UseFormReturn<Schema>) => React.CSSProperties)
readonly children:
| React.ReactNode
| ((
props: components.UseFormReturn<Schema> & {
readonly form: components.UseFormReturn<Schema>
},
) => React.ReactNode)
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
readonly className?: string | ((props: FormStateRenderProps<Schema>) => string)
readonly className?: string | ((props: components.UseFormReturn<Schema>) => string)
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void

View File

@ -39,7 +39,7 @@ export const INPUT_STYLES = tv({
variant: {
custom: {},
outline: {
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[-1px] focus-within:outline-primary',
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[0.5px] focus-within:outline-primary',
textArea: 'border-transparent focus-within:border-transparent',
},
},

View File

@ -0,0 +1,140 @@
/**
* @file
*
* A switch allows a user to turn a setting on or off.
*/
import {
Switch as AriaSwitch,
mergeProps,
type SwitchProps as AriaSwitchProps,
} from '#/components/aria'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import type { CSSProperties, ForwardedRef } from 'react'
import { useRef } from 'react'
import { tv, type VariantProps } from 'tailwind-variants'
import { Form, type FieldPath, type FieldProps, type FieldStateProps, type TSchema } from '../Form'
import { TEXT_STYLE } from '../Text'
/**
* Props for the {@Switch} component.
*/
export interface SwitchProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends FieldStateProps<
Omit<AriaSwitchProps, 'children' | 'size' | 'value'> & { value: boolean },
Schema,
TFieldName
>,
FieldProps,
Omit<VariantProps<typeof SWITCH_STYLES>, 'disabled' | 'invalid'> {
readonly className?: string
readonly style?: CSSProperties
}
export const SWITCH_STYLES = tv({
base: '',
variants: {
disabled: { true: 'cursor-not-allowed opacity-50' },
size: {
small: {
background: 'h-4 w-7 p-0.5',
},
},
},
slots: {
switch: 'group flex items-center gap-1',
label: TEXT_STYLE({
variant: 'body',
color: 'primary',
className: 'flex-1',
}),
background:
'flex shrink-0 cursor-default items-center rounded-full bg-primary/30 bg-clip-padding shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-primary/60 group-selected:bg-primary group-selected:group-pressed:bg-primary/50',
thumb:
'aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]',
},
defaultVariants: {
size: 'small',
disabled: false,
},
})
/**
* A switch allows a user to turn a setting on or off.
*/
// eslint-disable-next-line no-restricted-syntax
export const Switch = forwardRef(function Switch<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(props: SwitchProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
const {
label,
isDisabled = false,
isRequired = false,
defaultValue,
className,
name,
form,
description,
error,
size,
...ariaSwitchProps
} = props
const switchRef = useRef<HTMLInputElement>(null)
const { fieldState, formInstance, field } = Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const { ref: fieldRef, ...fieldProps } = formInstance.register(name, {
disabled: isDisabled,
required: isRequired,
...(props.onBlur && { onBlur: props.onBlur }),
...(props.onChange && { onChange: props.onChange }),
})
const {
base,
thumb,
background,
label: labelStyle,
switch: switchStyles,
} = SWITCH_STYLES({ size, disabled: fieldProps.disabled })
return (
<Form.Field
ref={ref}
form={formInstance}
name={name}
className={base({ className })}
fullWidth
description={description}
error={error}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']}
isRequired={fieldProps.required}
isInvalid={fieldState.invalid}
aria-details={props['aria-details']}
style={props.style}
>
<AriaSwitch
ref={mergeRefs(switchRef, fieldRef)}
{...mergeProps<AriaSwitchProps>()(ariaSwitchProps, fieldProps, {
defaultSelected: field.value,
className: switchStyles(),
})}
>
<div className={background()} role="presentation">
<span className={thumb()} />
</div>
<div className={labelStyle()}>{label}</div>
</AriaSwitch>
</Form.Field>
)
})

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel file for Switch component.
*/
export * from './Switch'

View File

@ -12,7 +12,7 @@ import * as text from '../Text'
// =================
export const TOOLTIP_STYLES = twv.tv({
base: 'group flex justify-center items-center text-center text-balance break-words z-50',
base: 'group flex justify-center items-center text-center text-balance break-all z-50',
variants: {
variant: {
custom: '',

View File

@ -10,6 +10,7 @@ export * from './Form'
export * from './Inputs'
export * from './Radio'
export * from './Separator'
export * from './Switch'
export * from './Text'
export * from './Tooltip'
export * from './VisuallyHidden'

View File

@ -17,17 +17,19 @@ import * as billing from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import { UserSessionType } from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import {
useEnableVersionChecker,
usePaywallDevtools,
useSetEnableVersionChecker,
} from '#/providers/EnsoDevtoolsProvider'
import * as textProvider from '#/providers/TextProvider'
} from './EnsoDevtoolsProvider'
import * as ariaComponents from '#/components/AriaComponents'
import Portal from '#/components/Portal'
import { Switch } from '#/components/aria'
import {
Button,
ButtonGroup,
DialogTrigger,
Form,
Popover,
Radio,
@ -35,246 +37,249 @@ import {
Separator,
Text,
} from '#/components/AriaComponents'
import Portal from '#/components/Portal'
import {
FEATURE_FLAGS_SCHEMA,
useFeatureFlags,
useSetFeatureFlags,
} from '#/providers/FeatureFlagsProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import * as backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
/**
* Configuration for a paywall feature.
*/
export interface PaywallDevtoolsFeatureConfiguration {
readonly isForceEnabled: boolean | null
}
const PaywallDevtoolsContext = React.createContext<{
features: Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
}>({
features: {
share: { isForceEnabled: null },
shareFull: { isForceEnabled: null },
userGroups: { isForceEnabled: null },
userGroupsFull: { isForceEnabled: null },
inviteUser: { isForceEnabled: null },
inviteUserFull: { isForceEnabled: null },
},
})
/**
* Props for the {@link EnsoDevtools} component.
*/
interface EnsoDevtoolsProps extends React.PropsWithChildren {}
/**
* A component that provides a UI for toggling paywall features.
*/
export function EnsoDevtools(props: EnsoDevtoolsProps) {
const { children } = props
export function EnsoDevtools() {
const { getText } = textProvider.useText()
const { authQueryKey, session } = authProvider.useAuth()
const queryClient = reactQuery.useQueryClient()
const { getFeature } = billing.usePaywallFeatures()
const { features, setFeature } = usePaywallDevtools()
const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker()
const { localStorage } = useLocalStorage()
const [features, setFeatures] = React.useState<
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
>({
share: { isForceEnabled: null },
shareFull: { isForceEnabled: null },
userGroups: { isForceEnabled: null },
userGroupsFull: { isForceEnabled: null },
inviteUser: { isForceEnabled: null },
inviteUserFull: { isForceEnabled: null },
})
const { getFeature } = billing.usePaywallFeatures()
const queryClient = reactQuery.useQueryClient()
const onConfigurationChange = React.useCallback(
(feature: billing.PaywallFeatureName, configuration: PaywallDevtoolsFeatureConfiguration) => {
setFeatures((prev) => ({ ...prev, [feature]: configuration }))
},
[],
)
const featureFlags = useFeatureFlags()
const setFeatureFlags = useSetFeatureFlags()
return (
<PaywallDevtoolsContext.Provider value={{ features }}>
{children}
<Portal>
<ariaComponents.DialogTrigger>
<ariaComponents.Button
icon={DevtoolsLogo}
aria-label={getText('ensoDevtoolsButtonLabel')}
variant="icon"
rounded="full"
size="hero"
className="fixed bottom-16 right-3 z-50"
data-ignore-click-outside
/>
<Portal>
<DialogTrigger>
<Button
icon={DevtoolsLogo}
aria-label={getText('paywallDevtoolsButtonLabel')}
variant="icon"
rounded="full"
size="hero"
className="fixed bottom-16 right-3 z-50"
data-ignore-click-outside
/>
<Popover>
<Text.Heading disableLineHeightCompensation>
{getText('ensoDevtoolsPopoverHeading')}
</Text.Heading>
<Popover>
<Text.Heading disableLineHeightCompensation>
{getText('paywallDevtoolsPopoverHeading')}
</Text.Heading>
<Separator orientation="horizontal" className="my-3" />
<Separator orientation="horizontal" className="my-3" />
{session?.type === UserSessionType.full && (
<>
<Text variant="subtitle">{getText('ensoDevtoolsPlanSelectSubtitle')}</Text>
{session?.type === UserSessionType.full && (
<>
<Text variant="subtitle">{getText('paywallDevtoolsPlanSelectSubtitle')}</Text>
<Form
gap="small"
schema={(schema) => schema.object({ plan: schema.string() })}
defaultValues={{ plan: session.user.plan ?? 'free' }}
>
{({ form }) => (
<>
<RadioGroup
form={form}
name="plan"
onChange={(value) => {
queryClient.setQueryData(authQueryKey, {
...session,
user: { ...session.user, plan: value },
})
}}
>
<Radio label={getText('free')} value={'free'} />
<Radio label={getText('solo')} value={backend.Plan.solo} />
<Radio label={getText('team')} value={backend.Plan.team} />
<Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
</RadioGroup>
<Button
size="small"
variant="outline"
onPress={() =>
queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => {
form.reset()
})
}
>
{getText('reset')}
</Button>
</>
)}
</Form>
<Separator orientation="horizontal" className="my-3" />
{/* eslint-disable-next-line no-restricted-syntax */}
<Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
Open setup page
</Button>
<Separator orientation="horizontal" className="my-3" />
</>
)}
<Text variant="subtitle" className="mb-2">
{getText('productionOnlyFeatures')}
</Text>
<div className="flex flex-col">
<Switch
className="group flex items-center gap-1"
isSelected={enableVersionChecker ?? !IS_DEV_MODE}
onChange={setEnableVersionChecker}
<Form
gap="small"
schema={(schema) => schema.object({ plan: schema.nativeEnum(backend.Plan) })}
defaultValues={{ plan: session.user.plan ?? backend.Plan.free }}
>
<div className="box-border flex h-4 w-[28px] shrink-0 cursor-default items-center rounded-full bg-primary/30 bg-clip-padding p-0.5 shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-primary/60 group-selected:bg-primary group-selected:group-pressed:bg-primary/50">
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div>
<Text className="flex-1">{getText('enableVersionChecker')}</Text>
</Switch>
<Text variant="body" color="disabled">
{getText('enableVersionCheckerDescription')}
</Text>
</div>
<Separator orientation="horizontal" className="my-3" />
<Text variant="subtitle" className="mb-2">
{getText('localStorage')}
</Text>
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
<div className="flex gap-1">
<ButtonGroup className="grow-0">
<Button
size="small"
variant="outline"
onPress={() => {
localStorage.delete(key)
}}
>
{getText('delete')}
</Button>
</ButtonGroup>
<Text variant="body">
{key
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
.replace(/^./, (m) => m.toUpperCase())}
</Text>
</div>
))}
<Separator orientation="horizontal" className="my-3" />
<Text variant="subtitle" className="mb-2">
{getText('paywallDevtoolsPaywallFeaturesToggles')}
</Text>
<div className="flex flex-col gap-1">
{Object.entries(features).map(([feature, configuration]) => {
// eslint-disable-next-line no-restricted-syntax
const featureName = feature as billing.PaywallFeatureName
const { label, descriptionTextId } = getFeature(featureName)
return (
<div key={feature} className="flex flex-col">
<Switch
className="group flex items-center gap-1"
isSelected={configuration.isForceEnabled ?? true}
{({ form }) => (
<>
<RadioGroup
name="plan"
onChange={(value) => {
onConfigurationChange(featureName, {
isForceEnabled: value,
queryClient.setQueryData(authQueryKey, {
...session,
user: { ...session.user, plan: value },
})
}}
>
<div className="box-border flex h-4 w-[28px] shrink-0 cursor-default items-center rounded-full bg-primary/30 bg-clip-padding p-0.5 shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-primary/60 group-selected:bg-primary group-selected:group-pressed:bg-primary/50">
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div>
<Radio label={getText('free')} value={backend.Plan.free} />
<Radio label={getText('solo')} value={backend.Plan.solo} />
<Radio label={getText('team')} value={backend.Plan.team} />
<Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
</RadioGroup>
<Text className="flex-1">{getText(label)}</Text>
</Switch>
<Button
size="small"
variant="outline"
onPress={() =>
queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => {
form.reset()
})
}
>
{getText('reset')}
</Button>
</>
)}
</Form>
<Text variant="body" color="disabled">
{getText(descriptionTextId)}
</Text>
</div>
)
})}
<Separator orientation="horizontal" className="my-3" />
{/* eslint-disable-next-line no-restricted-syntax */}
<Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
Open setup page
</Button>
<Separator orientation="horizontal" className="my-3" />
</>
)}
<ariaComponents.Text variant="subtitle" className="mb-2">
{getText('productionOnlyFeatures')}
</ariaComponents.Text>
<ariaComponents.Form
schema={(z) => z.object({ enableVersionChecker: z.boolean() })}
defaultValues={{ enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE }}
>
{({ form }) => (
<ariaComponents.Switch
form={form}
name="enableVersionChecker"
label={getText('enableVersionChecker')}
description={getText('enableVersionCheckerDescription')}
onChange={(value) => {
setEnableVersionChecker(value)
}}
/>
)}
</ariaComponents.Form>
<Separator orientation="horizontal" className="my-3" />
<Text variant="subtitle" className="mb-2">
{getText('localStorage')}
</Text>
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
<div className="flex gap-1">
<ButtonGroup className="grow-0">
<Button
size="small"
variant="outline"
onPress={() => {
localStorage.delete(key)
}}
>
{getText('delete')}
</Button>
</ButtonGroup>
<Text variant="body">
{key
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
.replace(/^./, (m) => m.toUpperCase())}
</Text>
</div>
</Popover>
</DialogTrigger>
</Portal>
</PaywallDevtoolsContext.Provider>
))}
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<ariaComponents.Text variant="subtitle" className="mb-2">
{getText('ensoDevtoolsFeatureFlags')}
<ariaComponents.Form
gap="small"
formOptions={{ mode: 'onChange' }}
schema={FEATURE_FLAGS_SCHEMA}
defaultValues={{
enableMultitabs: featureFlags.enableMultitabs,
enableAssetsTableBackgroundRefresh: featureFlags.enableAssetsTableBackgroundRefresh,
assetsTableBackgroundRefreshInterval:
featureFlags.assetsTableBackgroundRefreshInterval,
}}
>
{(form) => (
<>
<ariaComponents.Switch
form={form}
name="enableMultitabs"
label={getText('enableMultitabs')}
description={getText('enableMultitabsDescription')}
onChange={(value) => {
setFeatureFlags('enableMultitabs', value)
}}
/>
<div>
<ariaComponents.Switch
form={form}
name="enableAssetsTableBackgroundRefresh"
label={getText('enableAssetsTableBackgroundRefresh')}
description={getText('enableAssetsTableBackgroundRefreshDescription')}
onChange={(value) => {
setFeatureFlags('enableAssetsTableBackgroundRefresh', value)
}}
/>
<ariaComponents.Input
form={form}
type="number"
inputMode="numeric"
name="assetsTableBackgroundRefreshInterval"
label={getText('enableAssetsTableBackgroundRefreshInterval')}
description={getText('enableAssetsTableBackgroundRefreshIntervalDescription')}
onChange={(event) => {
setFeatureFlags(
'assetsTableBackgroundRefreshInterval',
event.target.valueAsNumber,
)
}}
/>
</div>
</>
)}
</ariaComponents.Form>
</ariaComponents.Text>
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<ariaComponents.Text variant="subtitle" className="mb-2">
{getText('ensoDevtoolsPaywallFeaturesToggles')}
</ariaComponents.Text>
<ariaComponents.Form
gap="small"
schema={(z) =>
z.object(Object.fromEntries(Object.keys(features).map((key) => [key, z.boolean()])))
}
defaultValues={Object.fromEntries(
Object.keys(features).map((feature) => {
// eslint-disable-next-line no-restricted-syntax
const featureName = feature as billing.PaywallFeatureName
return [featureName, features[featureName].isForceEnabled ?? true]
}),
)}
>
{Object.keys(features).map((feature) => {
// eslint-disable-next-line no-restricted-syntax
const featureName = feature as billing.PaywallFeatureName
const { label, descriptionTextId } = getFeature(featureName)
return (
<ariaComponents.Switch
key={feature}
name={featureName}
label={getText(label)}
description={getText(descriptionTextId)}
onChange={(value) => {
setFeature(featureName, value)
}}
/>
)
})}
</ariaComponents.Form>
</Popover>
</ariaComponents.DialogTrigger>
</Portal>
)
}
/**
* A hook that provides access to the paywall devtools.
*/
export function usePaywallDevtools() {
const context = React.useContext(PaywallDevtoolsContext)
React.useDebugValue(context)
return context
}

View File

@ -0,0 +1,73 @@
/**
* @file
* This file provides a zustand store that contains the state of the Enso devtools.
*/
import type { PaywallFeatureName } from '#/hooks/billing'
import * as zustand from 'zustand'
/**
* Configuration for a paywall feature.
*/
export interface PaywallDevtoolsFeatureConfiguration {
readonly isForceEnabled: boolean | null
}
// =========================
// === EnsoDevtoolsStore ===
// =========================
/** The state of this zustand store. */
interface EnsoDevtoolsStore {
readonly showVersionChecker: boolean | null
readonly paywallFeatures: Record<PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
readonly setPaywallFeature: (feature: PaywallFeatureName, isForceEnabled: boolean | null) => void
readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void
}
const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) => ({
showVersionChecker: false,
paywallFeatures: {
share: { isForceEnabled: null },
shareFull: { isForceEnabled: null },
userGroups: { isForceEnabled: null },
userGroupsFull: { isForceEnabled: null },
inviteUser: { isForceEnabled: null },
inviteUserFull: { isForceEnabled: null },
},
setPaywallFeature: (feature, isForceEnabled) => {
set((state) => ({
paywallFeatures: { ...state.paywallFeatures, [feature]: { isForceEnabled } },
}))
},
setEnableVersionChecker: (showVersionChecker) => {
set({ showVersionChecker })
},
}))
// ===============================
// === useEnableVersionChecker ===
// ===============================
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useEnableVersionChecker() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.showVersionChecker)
}
// ==================================
// === useSetEnableVersionChecker ===
// ==================================
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useSetEnableVersionChecker() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.setEnableVersionChecker)
}
/**
* A hook that provides access to the paywall devtools.
*/
export function usePaywallDevtools() {
return zustand.useStore(ensoDevtoolsStore, (state) => ({
features: state.paywallFeatures,
setFeature: state.setPaywallFeature,
}))
}

View File

@ -5,4 +5,5 @@
*/
export * from './EnsoDevtools'
export * from './EnsoDevtoolsProvider'
export * from './ReactQueryDevtools'

View File

@ -1,64 +1,58 @@
/** @file A table row for an arbitrary asset. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import { useStore } from 'zustand'
import BlankIcon from '#/assets/blank.svg'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
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'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import FocusRing from '#/components/styled/FocusRing'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable'
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 * as localBackend from '#/services/LocalBackend'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { isCloudCategory } from '#/layouts/CategorySwitcher/Category'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useFullUserSession } from '#/providers/AuthProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
import { download } from '#/utilities/download'
import * as drag from '#/utilities/drag'
import * as eventModule from '#/utilities/event'
import * as fileInfo from '#/utilities/fileInfo'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as path from '#/utilities/path'
import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
// =================
// === Constants ===
// =================
/** The height of the header row. */
const HEADER_HEIGHT_PX = 34
const HEADER_HEIGHT_PX = 40
/** The amount of time (in milliseconds) the drag item must be held over this component
* to make a directory row expand. */
const DRAG_EXPAND_DELAY_MS = 500
@ -96,12 +90,24 @@ export interface AssetRowProps
export default function AssetRow(props: AssetRowProps) {
const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const {
nodeMap,
setAssetPanelProps,
doToggleDirectoryExpansion,
doCopy,
doCut,
doPaste,
doDelete: doDeleteRaw,
doRestore,
doMove,
category,
} = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
const { visibilities, category } = state
const { visibilities } = state
const [item, setItem] = React.useState(rawItem)
const driveStore = useDriveStore()
const { user } = useFullUserSession()
const setSelectedKeys = useSetSelectedKeys()
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
(visuallySelectedKeys ?? selectedKeys).has(item.key),
@ -115,14 +121,10 @@ export default function AssetRow(props: AssetRowProps) {
({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected,
)
const draggableProps = dragAndDropHooks.useDraggable()
const { user } = authProvider.useFullUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const { data: users } = useBackendQuery(backend, 'listUsers', [])
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
@ -137,33 +139,14 @@ export default function AssetRow(props: AssetRowProps) {
readonly nodeMap: WeakRef<ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>>
readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId>
} | null>(null)
const isCloud = isCloudCategory(category)
const outerVisibility = visibilities.get(item.key)
const visibility =
outerVisibility == null || outerVisibility === Visibility.visible ?
insertionVisibility
: outerVisibility
const hidden = hiddenRaw || visibility === Visibility.hidden
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
const openProjectMutation = useMutation(backendMutationOptions(backend, 'openProject'))
const closeProjectMutation = useMutation(backendMutationOptions(backend, 'closeProject'))
const getProjectDetailsMutation = useMutation(
backendMutationOptions(backend, 'getProjectDetails'),
)
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const copyAsset = copyAssetMutation.mutateAsync
const updateAsset = updateAssetMutation.mutateAsync
const deleteAsset = deleteAssetMutation.mutateAsync
const undoDeleteAsset = undoDeleteAssetMutation.mutateAsync
const openProject = openProjectMutation.mutateAsync
const closeProject = closeProjectMutation.mutateAsync
const isCloud = isCloudCategory(category)
const { data: projectState } = useQuery({
// This is SAFE, as `isOpened` is only true for projects.
@ -173,6 +156,16 @@ export default function AssetRow(props: AssetRowProps) {
enabled: item.type === backendModule.AssetType.project,
})
const toastAndLog = useToastAndLog()
const getProjectDetailsMutation = useMutation(
backendMutationOptions(backend, 'getProjectDetails'),
)
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState()
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
@ -204,155 +197,6 @@ export default function AssetRow(props: AssetRowProps) {
React.useImperativeHandle(updateAssetRef, () => setAsset)
const doCopyOnBackend = React.useCallback(
async (newParentId: backendModule.DirectoryId | null) => {
try {
setAsset((oldAsset) =>
object.merge(oldAsset, {
title: oldAsset.title + ' (copy)',
labels: [],
permissions: permissions.tryCreateOwnerPermission(
`${item.path} (copy)`,
category,
user,
users ?? [],
userGroups ?? [],
),
modifiedAt: dateTime.toRfc3339(new Date()),
}),
)
newParentId ??= rootDirectoryId
const copiedAsset = await copyAsset([
asset.id,
newParentId,
asset.title,
nodeMap.current.get(newParentId)?.item.title ?? '(unknown)',
])
setAsset(
// This is SAFE, as the type of the copied asset is guaranteed to be the same
// as the type of the original asset.
// eslint-disable-next-line no-restricted-syntax
object.merger({
...copiedAsset.asset,
state: { type: backendModule.ProjectState.new },
} as Partial<backendModule.AnyAsset>),
)
} catch (error) {
toastAndLog('copyAssetError', error, asset.title)
// Delete the new component representing the asset that failed to insert.
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
}
},
[
setAsset,
rootDirectoryId,
copyAsset,
asset.id,
asset.title,
nodeMap,
item.path,
item.key,
category,
user,
users,
userGroups,
toastAndLog,
dispatchAssetListEvent,
],
)
const doMove = React.useCallback(
async (
newParentKey: backendModule.DirectoryId | null,
newParentId: backendModule.DirectoryId | null,
) => {
const nonNullNewParentKey = newParentKey ?? rootDirectoryId
const nonNullNewParentId = newParentId ?? rootDirectoryId
try {
setItem((oldItem) =>
oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId }),
)
const newParentPath = localBackend.extractTypeAndId(nonNullNewParentId).id
let newId = asset.id
if (!isCloud) {
const oldPath = localBackend.extractTypeAndId(asset.id).id
const newPath = path.joinPath(newParentPath, fileInfo.getFileName(oldPath))
switch (asset.type) {
case backendModule.AssetType.file: {
newId = localBackend.newFileId(newPath)
break
}
case backendModule.AssetType.directory: {
newId = localBackend.newDirectoryId(newPath)
break
}
case backendModule.AssetType.project:
case backendModule.AssetType.secret:
case backendModule.AssetType.datalink:
case backendModule.AssetType.specialLoading:
case backendModule.AssetType.specialEmpty: {
// Ignored.
// Project paths are not stored in their `id`;
// The other asset types either do not exist on the Local backend,
// or do not have a path.
break
}
}
}
// This is SAFE as the type of `newId` is not changed from its original type.
// eslint-disable-next-line no-restricted-syntax
const newAsset = object.merge(asset, { id: newId as never, parentId: nonNullNewParentId })
dispatchAssetListEvent({
type: AssetListEventType.move,
newParentKey: nonNullNewParentKey,
newParentId: nonNullNewParentId,
key: item.key,
item: newAsset,
})
setAsset(newAsset)
await updateAsset([
asset.id,
{ parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
asset.title,
])
} catch (error) {
toastAndLog('moveAssetError', error, asset.title)
setAsset(
object.merger({
// This is SAFE as the type of `newId` is not changed from its original type.
// eslint-disable-next-line no-restricted-syntax
id: asset.id as never,
parentId: asset.parentId,
projectState: asset.projectState,
}),
)
setItem((oldItem) =>
oldItem.with({ directoryKey: item.directoryKey, directoryId: item.directoryId }),
)
// Move the asset back to its original position.
dispatchAssetListEvent({
type: AssetListEventType.move,
newParentKey: item.directoryKey,
newParentId: item.directoryId,
key: item.key,
item: asset,
})
}
},
[
isCloud,
asset,
rootDirectoryId,
item.directoryId,
item.directoryKey,
item.key,
toastAndLog,
updateAsset,
setAsset,
dispatchAssetListEvent,
],
)
React.useEffect(() => {
if (isSoleSelected) {
setAssetPanelProps({ backend, item, setItem })
@ -361,66 +205,12 @@ export default function AssetRow(props: AssetRowProps) {
}, [item, isSoleSelected, backend, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
const doDelete = React.useCallback(
async (forever = false) => {
setInsertionVisibility(Visibility.hidden)
if (asset.type === backendModule.AssetType.directory) {
dispatchAssetListEvent({
type: AssetListEventType.closeFolder,
id: asset.id,
// This is SAFE, as this asset is already known to be a directory.
// eslint-disable-next-line no-restricted-syntax
key: item.key as backendModule.DirectoryId,
})
}
try {
dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: item.key })
if (
asset.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.local
) {
if (
asset.projectState.type !== backendModule.ProjectState.placeholder &&
asset.projectState.type !== backendModule.ProjectState.closed
) {
await openProject([asset.id, null, asset.title])
}
try {
await closeProject([asset.id, asset.title])
} catch {
// Ignored. The project was already closed.
}
}
await deleteAsset([asset.id, { force: forever }, asset.title])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog('deleteAssetError', error, asset.title)
}
(forever = false) => {
void doDeleteRaw(item.item, forever)
},
[
backend,
dispatchAssetListEvent,
asset,
openProject,
closeProject,
deleteAsset,
item.key,
toastAndLog,
],
[doDeleteRaw, item.item],
)
const doRestore = React.useCallback(async () => {
// Visually, the asset is deleted from the Trash view.
setInsertionVisibility(Visibility.hidden)
try {
await undoDeleteAsset([asset.id, asset.title])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog('restoreAssetError', error, asset.title)
}
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAsset, item.key])
const doTriggerDescriptionEdit = React.useCallback(() => {
setModal(
<EditAssetDescriptionModal
@ -441,18 +231,63 @@ export default function AssetRow(props: AssetRowProps) {
)
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title])
const clearDragState = React.useCallback(() => {
setIsDraggedOver(false)
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
}, [])
const onDragOver = (event: React.DragEvent<Element>) => {
const directoryKey =
item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey
const payload = drag.ASSET_ROWS.lookup(event)
const isPayloadMatch =
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
const canPaste = (() => {
if (!isPayloadMatch) {
return true
} else {
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
const parentKeys = new Map(
Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [
id,
otherAsset.directoryKey,
]),
)
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
}
return !payload.some((payloadItem) => {
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
return !parent ? true : (
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
)
})
}
})()
if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) {
event.preventDefault()
if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') {
setIsDraggedOver(true)
}
}
}
eventListProvider.useAssetEventListener(async (event) => {
if (state.category.type === 'trash') {
switch (event.type) {
case AssetEventType.deleteForever: {
if (event.ids.has(item.key)) {
await doDelete(true)
doDelete(true)
}
break
}
case AssetEventType.restore: {
if (event.ids.has(item.key)) {
await doRestore()
await doRestore(item.item)
}
break
}
@ -462,22 +297,6 @@ export default function AssetRow(props: AssetRowProps) {
}
} else {
switch (event.type) {
// These events are handled in the specific `NameColumn` files.
case AssetEventType.newProject:
case AssetEventType.newFolder:
case AssetEventType.uploadFiles:
case AssetEventType.newDatalink:
case AssetEventType.newSecret:
case AssetEventType.updateFiles:
case AssetEventType.projectClosed: {
break
}
case AssetEventType.copy: {
if (event.ids.has(item.key)) {
await doCopyOnBackend(event.newParentId)
}
break
}
case AssetEventType.cut: {
if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.faded)
@ -493,25 +312,25 @@ export default function AssetRow(props: AssetRowProps) {
case AssetEventType.move: {
if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.visible)
await doMove(event.newParentKey, event.newParentId)
await doMove(event.newParentKey, item.item)
}
break
}
case AssetEventType.delete: {
if (event.ids.has(item.key)) {
await doDelete(false)
doDelete(false)
}
break
}
case AssetEventType.deleteForever: {
if (event.ids.has(item.key)) {
await doDelete(true)
doDelete(true)
}
break
}
case AssetEventType.restore: {
if (event.ids.has(item.key)) {
await doRestore()
await doRestore(item.item)
}
break
}
@ -559,7 +378,7 @@ export default function AssetRow(props: AssetRowProps) {
try {
const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title])
const fileName = `${asset.title}.datalink`
download.download(
download(
URL.createObjectURL(
new File([JSON.stringify(value)], fileName, {
type: 'application/json+x-enso-data-link',
@ -712,55 +531,13 @@ export default function AssetRow(props: AssetRowProps) {
}
break
}
default: {
return
}
}
}
}, item.initialAssetEvents)
const clearDragState = React.useCallback(() => {
setIsDraggedOver(false)
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
}, [])
const onDragOver = (event: React.DragEvent<Element>) => {
const directoryKey =
item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey
const payload = drag.ASSET_ROWS.lookup(event)
const isPayloadMatch =
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
const canPaste = (() => {
if (!isPayloadMatch) {
return true
} else {
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
const parentKeys = new Map(
Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [
id,
otherAsset.directoryKey,
]),
)
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
}
return !payload.some((payloadItem) => {
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
return !parent ? true : (
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
)
})
}
})()
if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) {
event.preventDefault()
if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') {
setIsDraggedOver(true)
}
}
}
switch (asset.type) {
case backendModule.AssetType.directory:
case backendModule.AssetType.project:
@ -784,18 +561,23 @@ export default function AssetRow(props: AssetRowProps) {
tabIndex={0}
ref={(element) => {
rootRef.current = element
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
const rect = element.getBoundingClientRect()
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
const scrollDown = rect.bottom - scrollRect.bottom
if (scrollUp < 0 || scrollDown > 0) {
scrollContainerRef.current.scrollBy({
top: scrollUp < 0 ? scrollUp : scrollDown,
behavior: 'smooth',
})
requestAnimationFrame(() => {
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
const rect = element.getBoundingClientRect()
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
const scrollDown = rect.bottom - scrollRect.bottom
if (scrollUp < 0 || scrollDown > 0) {
scrollContainerRef.current.scrollBy({
top: scrollUp < 0 ? scrollUp : scrollDown,
behavior: 'smooth',
})
}
}
}
})
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
element.focus()
}
@ -819,7 +601,7 @@ export default function AssetRow(props: AssetRowProps) {
window.setTimeout(() => {
setSelected(false)
})
doToggleDirectoryExpansion(item.item.id, item.key, asset.title)
doToggleDirectoryExpansion(item.item.id, item.key)
}
}}
onContextMenu={(event) => {
@ -864,7 +646,7 @@ export default function AssetRow(props: AssetRowProps) {
}
if (item.type === backendModule.AssetType.directory) {
dragOverTimeoutHandle.current = window.setTimeout(() => {
doToggleDirectoryExpansion(item.item.id, item.key, asset.title, true)
doToggleDirectoryExpansion(item.item.id, item.key, true)
}, DRAG_EXPAND_DELAY_MS)
}
// Required because `dragover` does not fire on `mouseenter`.
@ -902,10 +684,10 @@ export default function AssetRow(props: AssetRowProps) {
if (state.category.type !== 'trash') {
props.onDrop?.(event)
clearDragState()
const [directoryKey, directoryId, directoryTitle] =
const [directoryKey, directoryId] =
item.type === backendModule.AssetType.directory ?
[item.key, item.item.id, asset.title]
: [item.directoryKey, item.directoryId, null]
[item.key, item.item.id]
: [item.directoryKey, item.directoryId]
const payload = drag.ASSET_ROWS.lookup(event)
if (
payload != null &&
@ -914,7 +696,7 @@ export default function AssetRow(props: AssetRowProps) {
event.preventDefault()
event.stopPropagation()
unsetModal()
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
doToggleDirectoryExpansion(directoryId, directoryKey, true)
const ids = payload
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
.map((dragItem) => dragItem.key)
@ -927,7 +709,7 @@ export default function AssetRow(props: AssetRowProps) {
} else if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
doToggleDirectoryExpansion(directoryId, directoryKey, true)
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: directoryKey,

View File

@ -1,21 +1,12 @@
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import DatalinkIcon from '#/assets/datalink.svg'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
@ -25,7 +16,6 @@ import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
// ====================
// === DatalinkName ===
@ -39,10 +29,8 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, setIsAssetPanelTemporarilyVisible } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { setIsAssetPanelTemporarilyVisible } = state
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.datalink) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`DatalinkNameColumn` can only display Datalinks.')
@ -50,8 +38,6 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {
setRowState(object.merger({ isEditingName }))
@ -61,68 +47,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
const doRename = async () => {
await Promise.resolve(null)
}
eventListProvider.useAssetEventListener(async (event) => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.newFolder:
case AssetEventType.uploadFiles:
case AssetEventType.newSecret:
case AssetEventType.updateFiles:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel:
case AssetEventType.setItem:
case AssetEventType.projectClosed: {
// Ignored. These events should all be unrelated to secrets.
// `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected`
// are handled by `AssetRow`.
break
}
case AssetEventType.newDatalink: {
if (item.key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('localBackendDatalinkError')
} else {
rowState.setVisibility(Visibility.faded)
try {
const { id } = await createDatalinkMutation.mutateAsync([
{
parentDirectoryId: asset.parentId,
datalinkId: null,
name: asset.title,
value: event.value,
},
])
rowState.setVisibility(Visibility.visible)
setAsset(object.merger({ id }))
} catch (error) {
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
toastAndLog('createDatalinkError', error)
}
}
}
break
}
}
}
}, item.initialAssetEvents)
const doRename = () => Promise.resolve(null)
const handleClick = inputBindings.handler({
editName: () => {
@ -171,7 +96,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
onCancel={() => {
setIsEditing(false)
}}
className="text grow bg-transparent font-naming"
className="grow bg-transparent font-naming"
>
{asset.title}
</EditableSpan>

View File

@ -12,11 +12,6 @@ import { useDriveStore } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
@ -30,7 +25,6 @@ import * as object from '#/utilities/object'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import * as validation from '#/utilities/validation'
import Visibility from '#/utilities/Visibility'
// =====================
// === DirectoryName ===
@ -45,21 +39,22 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, nodeMap } = state
const { doToggleDirectoryExpansion } = state
const { doToggleDirectoryExpansion, expandedDirectoryIds } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const driveStore = useDriveStore()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.directory) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`DirectoryNameColumn` can only display folders.')
}
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const isExpanded = item.children != null && item.isExpanded
const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory'))
const isExpanded = item.children != null && expandedDirectoryIds.includes(asset.id)
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
const setIsEditing = (isEditingName: boolean) => {
@ -91,59 +86,6 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
}
}
eventListProvider.useAssetEventListener(async (event) => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.uploadFiles:
case AssetEventType.newDatalink:
case AssetEventType.newSecret:
case AssetEventType.updateFiles:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel:
case AssetEventType.setItem:
case AssetEventType.projectClosed: {
// Ignored. These events should all be unrelated to directories.
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
// are handled by`AssetRow`.
break
}
case AssetEventType.newFolder: {
if (item.key === event.placeholderId) {
rowState.setVisibility(Visibility.faded)
try {
const createdDirectory = await createDirectoryMutation.mutateAsync([
{
parentId: asset.parentId,
title: asset.title,
},
])
rowState.setVisibility(Visibility.visible)
setAsset(object.merge(asset, createdDirectory))
} catch (error) {
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
toastAndLog('createFolderError', error)
}
}
break
}
}
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {
setIsEditing(true)
@ -185,7 +127,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
isExpanded && 'rotate-90',
)}
onPress={() => {
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
doToggleDirectoryExpansion(asset.id, item.key)
}}
/>
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />
@ -193,7 +135,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
data-testid="asset-row-name"
editable={rowState.isEditingName}
className={tailwindMerge.twMerge(
'text grow cursor-pointer bg-transparent font-naming',
'grow cursor-pointer bg-transparent font-naming',
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer',
)}
checkSubmittable={(newTitle) =>

View File

@ -9,11 +9,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask'
@ -26,7 +21,6 @@ import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
// ================
// === FileName ===
@ -43,7 +37,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
const { backend, nodeMap } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.file) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`FileNameColumn` can only display files.')
@ -53,14 +47,6 @@ export default function FileNameColumn(props: FileNameColumnProps) {
const isCloud = backend.type === backendModule.BackendType.remote
const updateFileMutation = useMutation(backendMutationOptions(backend, 'updateFile'))
const uploadFileMutation = useMutation(
backendMutationOptions(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
}),
)
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {
@ -89,68 +75,6 @@ export default function FileNameColumn(props: FileNameColumnProps) {
}
}
eventListProvider.useAssetEventListener(async (event) => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.newFolder:
case AssetEventType.newDatalink:
case AssetEventType.newSecret:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel:
case AssetEventType.setItem:
case AssetEventType.projectClosed: {
// Ignored. These events should all be unrelated to projects.
// `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected`
// are handled by `AssetRow`.
break
}
case AssetEventType.updateFiles:
case AssetEventType.uploadFiles: {
const file = event.files.get(item.item.id)
if (file != null) {
const fileId = event.type !== AssetEventType.updateFiles ? null : asset.id
rowState.setVisibility(Visibility.faded)
try {
const createdFile = await uploadFileMutation.mutateAsync([
{ fileId, fileName: asset.title, parentDirectoryId: asset.parentId },
file,
])
rowState.setVisibility(Visibility.visible)
setAsset(object.merge(asset, { id: createdFile.id }))
} catch (error) {
switch (event.type) {
case AssetEventType.uploadFiles: {
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
toastAndLog(null, error)
break
}
case AssetEventType.updateFiles: {
toastAndLog(null, error)
break
}
}
}
}
break
}
}
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {
setIsEditing(true)
@ -182,7 +106,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
<EditableSpan
data-testid="asset-row-name"
editable={rowState.isEditingName}
className="text grow bg-transparent font-naming"
className="grow bg-transparent font-naming"
checkSubmittable={(newTitle) =>
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
}

View File

@ -144,8 +144,8 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
isDisabled={isDisabled}
isActive={!isDisabled || !isInput}
{...(isDisabled && error != null ? { title: error } : {})}
className={tailwindMerge.twMerge(
'flex-1 rounded-l-full border-0 py-0',
className={tailwindMerge.twJoin(
'flex-1 rounded-l-full',
permissions.PERMISSION_CLASS_NAME[permission.type],
)}
onPress={doShowPermissionTypeSelector}
@ -159,7 +159,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
isDisabled={isDisabled}
isActive={permission.docs && (!isDisabled || !isInput)}
{...(isDisabled && error != null ? { title: error } : {})}
className={tailwindMerge.twMerge('flex-1 border-0 py-0', permissions.DOCS_CLASS_NAME)}
className={tailwindMerge.twJoin('flex-1', permissions.DOCS_CLASS_NAME)}
onPress={() => {
setAction(
permissions.toPermissionAction({
@ -179,10 +179,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
isDisabled={isDisabled}
isActive={permission.execute && (!isDisabled || !isInput)}
{...(isDisabled && error != null ? { title: error } : {})}
className={tailwindMerge.twMerge(
'flex-1 rounded-r-full border-0 py-0',
permissions.EXEC_CLASS_NAME,
)}
className={tailwindMerge.twJoin('flex-1 rounded-r-full', permissions.EXEC_CLASS_NAME)}
onPress={() => {
setAction(
permissions.toPermissionAction({
@ -206,10 +203,11 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
variant="custom"
ref={permissionSelectorButtonRef}
isDisabled={isDisabled}
rounded="full"
isActive={!isDisabled || !isInput}
{...(isDisabled && error != null ? { title: error } : {})}
className={tailwindMerge.twMerge(
'w-[121px] rounded-full border-0 py-0',
className={tailwindMerge.twJoin(
'w-[121px]',
permissions.PERMISSION_CLASS_NAME[permission.type],
)}
onPress={doShowPermissionTypeSelector}

View File

@ -1,7 +1,7 @@
/** @file The icon and name of a {@link backendModule.ProjectAsset}. */
import * as React from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation } from '@tanstack/react-query'
import NetworkIcon from '#/assets/network.svg'
@ -15,19 +15,12 @@ import { useDriveStore } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import type * as column from '#/components/dashboard/column'
import ProjectIcon from '#/components/dashboard/ProjectIcon'
import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask'
import * as backendModule from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
@ -36,7 +29,6 @@ import * as permissions from '#/utilities/permissions'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import * as validation from '#/utilities/validation'
import Visibility from '#/utilities/Visibility'
// ===================
// === ProjectName ===
@ -61,12 +53,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
isOpened,
} = props
const { backend, nodeMap } = state
const client = useQueryClient()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const driveStore = useDriveStore()
const doOpenProject = projectHooks.useOpenProject()
@ -74,6 +65,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`ProjectNameColumn` can only display projects.')
}
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const ownPermission =
@ -96,20 +88,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const isOtherUserUsingProject =
isCloud && projectState.openedBy != null && projectState.openedBy !== user.email
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
const updateProjectMutation = useMutation(backendMutationOptions(backend, 'updateProject'))
const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
const getProjectDetailsMutation = useMutation(
backendMutationOptions(backend, 'getProjectDetails'),
)
const uploadFileMutation = useMutation(
backendMutationOptions(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
}),
)
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {
@ -131,9 +110,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
{ ami: null, ideVersion: null, projectName: newTitle },
asset.title,
])
await client.invalidateQueries({
queryKey: projectHooks.createGetProjectDetailsQuery.getQueryKey(asset.id),
})
} catch (error) {
toastAndLog('renameProjectError', error)
setAsset(object.merger({ title: oldTitle }))
@ -141,166 +117,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
}
eventListProvider.useAssetEventListener(async (event) => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newFolder:
case AssetEventType.newDatalink:
case AssetEventType.newSecret:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel:
case AssetEventType.setItem:
case AssetEventType.projectClosed: {
// Ignored. Any missing project-related events should be handled by `ProjectIcon`.
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
// are handled by`AssetRow`.
break
}
case AssetEventType.newProject: {
// This should only run before this project gets replaced with the actual project
// by this event handler. In both cases `key` will match, so using `key` here
// is a mistake.
if (asset.id === event.placeholderId) {
rowState.setVisibility(Visibility.faded)
try {
const createdProject =
event.originalId == null || event.versionId == null ?
await createProjectMutation.mutateAsync([
{
parentDirectoryId: asset.parentId,
projectName: asset.title,
...(event.templateId == null ?
{}
: { projectTemplateName: event.templateId }),
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
},
])
: await duplicateProjectMutation.mutateAsync([
event.originalId,
event.versionId,
asset.title,
])
event.onCreated?.(createdProject)
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {
id: createdProject.projectId,
projectState: object.merge(projectState, {
type: backendModule.ProjectState.placeholder,
}),
}),
)
doOpenProject({
id: createdProject.projectId,
type: backendType,
parentId: asset.parentId,
title: asset.title,
})
} catch (error) {
event.onError?.()
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
toastAndLog('createProjectError', error)
}
}
break
}
case AssetEventType.updateFiles:
case AssetEventType.uploadFiles: {
const file = event.files.get(item.key)
if (file != null) {
const fileId = event.type !== AssetEventType.updateFiles ? null : asset.id
rowState.setVisibility(Visibility.faded)
const { extension } = backendModule.extractProjectExtension(file.name)
const title = backendModule.stripProjectExtension(asset.title)
setAsset(object.merge(asset, { title }))
try {
if (backend.type === backendModule.BackendType.local) {
const directory = localBackend.extractTypeAndId(item.directoryId).id
let id: string
if (
'backendApi' in window &&
// This non-standard property is defined in Electron.
'path' in file &&
typeof file.path === 'string'
) {
id = await window.backendApi.importProjectFromPath(file.path, directory, title)
} else {
const searchParams = new URLSearchParams({ directory, name: title }).toString()
// Ideally this would use `file.stream()`, to minimize RAM
// requirements. for uploading large projects. Unfortunately,
// this requires HTTP/2, which is HTTPS-only, so it will not
// work on `http://localhost`.
const body =
window.location.protocol === 'https:' ? file.stream() : await file.arrayBuffer()
const path = `./api/upload-project?${searchParams}`
const response = await fetch(path, { method: 'POST', body })
id = await response.text()
}
const projectId = localBackend.newProjectId(projectManager.UUID(id))
const listedProject = await getProjectDetailsMutation.mutateAsync([
projectId,
asset.parentId,
file.name,
])
rowState.setVisibility(Visibility.visible)
setAsset(object.merge(asset, { title: listedProject.packageName, id: projectId }))
} else {
const createdFile = await uploadFileMutation.mutateAsync([
{
fileId,
fileName: `${title}.${extension}`,
parentDirectoryId: asset.parentId,
},
file,
])
const project = createdFile.project
if (project == null) {
throw new Error('The uploaded file was not a project.')
} else {
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {
title,
id: project.projectId,
projectState: project.state,
}),
)
return
}
}
} catch (error) {
switch (event.type) {
case AssetEventType.uploadFiles: {
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
toastAndLog('uploadProjectError', error)
break
}
case AssetEventType.updateFiles: {
toastAndLog('updateProjectError', error)
break
}
}
}
}
break
}
}
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {
setIsEditing(true)
@ -354,7 +170,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
data-testid="asset-row-name"
editable={rowState.isEditingName}
className={tailwindMerge.twMerge(
'text grow bg-transparent font-naming',
'grow bg-transparent font-naming',
canExecute && !isOtherUserUsingProject && 'cursor-pointer',
rowState.isEditingName && 'cursor-text',
)}

View File

@ -6,17 +6,11 @@ import { useMutation } from '@tanstack/react-query'
import KeyIcon from '#/assets/key.svg'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column'
import SvgMask from '#/components/SvgMask'
@ -29,7 +23,6 @@ import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
// =====================
// === ConnectorName ===
@ -42,19 +35,17 @@ export interface SecretNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.SecretAsset}.
* This should never happen. */
export default function SecretNameColumn(props: SecretNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { item, selected, state, rowState, setRowState, isEditable } = props
const { backend } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { setModal } = modalProvider.useSetModal()
const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.secret) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`SecretNameColumn` can only display secrets.')
}
const asset = item.item
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const setIsEditing = (isEditingName: boolean) => {
@ -63,69 +54,6 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
}
}
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
eventListProvider.useAssetEventListener(async (event) => {
if (isEditable) {
switch (event.type) {
case AssetEventType.newProject:
case AssetEventType.newFolder:
case AssetEventType.uploadFiles:
case AssetEventType.newDatalink:
case AssetEventType.updateFiles:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel:
case AssetEventType.setItem:
case AssetEventType.projectClosed: {
// Ignored. These events should all be unrelated to secrets.
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
// are handled by`AssetRow`.
break
}
case AssetEventType.newSecret: {
if (item.key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('localBackendSecretError')
} else {
rowState.setVisibility(Visibility.faded)
try {
const id = await createSecretMutation.mutateAsync([
{
parentDirectoryId: asset.parentId,
name: asset.title,
value: event.value,
},
])
rowState.setVisibility(Visibility.visible)
setAsset(object.merger({ id }))
} catch (error) {
dispatchAssetListEvent({
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('createSecretError', error)
}
}
}
break
}
}
}
}, item.initialAssetEvents)
const handleClick = inputBindings.handler({
editName: () => {
setIsEditing(true)

View File

@ -70,9 +70,9 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
return (
<div className="group flex items-center gap-column-items">
{(asset.permissions ?? []).map((other) => (
{(asset.permissions ?? []).map((other, idx) => (
<PermissionDisplay
key={backendModule.getAssetPermissionId(other)}
key={backendModule.getAssetPermissionId(other) + idx}
action={other.permission}
onPress={
setQuery == null ? null : (

View File

@ -115,7 +115,7 @@ interface AssetListMoveEvent extends AssetListBaseEvent<AssetListEventType.move>
readonly key: backend.AssetId
readonly newParentKey: backend.DirectoryId
readonly newParentId: backend.DirectoryId
readonly item: backend.AnyAsset
readonly items: backend.AnyAsset[]
}
/** A signal that a file has been deleted. */

View File

@ -48,7 +48,9 @@ export function useIntersectionRatio<T>(
const intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
setValue(transformRef.current(entry.intersectionRatio))
React.startTransition(() => {
setValue(transformRef.current(entry.intersectionRatio))
})
}
},
{ root, threshold },
@ -69,7 +71,9 @@ export function useIntersectionRatio<T>(
const dropzoneArea = dropzoneRect.width * dropzoneRect.height
const intersectionArea = intersectionRect.width * intersectionRect.height
const intersectionRatio = Math.max(0, dropzoneArea / intersectionArea)
setValue(transformRef.current(intersectionRatio))
React.startTransition(() => {
setValue(transformRef.current(intersectionRatio))
})
}
recomputeIntersectionRatio()
const resizeObserver = new ResizeObserver(() => {

View File

@ -21,6 +21,7 @@ import {
type LaunchedProjectId,
} from '#/providers/ProjectsProvider'
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
import * as backendModule from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
@ -231,11 +232,17 @@ export function useOpenProject() {
const addLaunchedProject = useAddLaunchedProject()
const closeAllProjects = useCloseAllProjects()
const openProjectMutation = useOpenProjectMutation()
const enableMultitabs = useFeatureFlag('enableMultitabs')
return eventCallbacks.useEventCallback((project: LaunchedProject) => {
// Since multiple tabs cannot be opened at the sametime, the opened projects need to be closed first.
if (projectsStore.getState().launchedProjects.length > 0) {
closeAllProjects()
if (!enableMultitabs) {
// Since multiple tabs cannot be opened at the same time, the opened projects need to be closed first.
if (projectsStore.getState().launchedProjects.length > 0) {
closeAllProjects()
}
}
const existingMutation = client.getMutationCache().find({
mutationKey: ['openProject'],
predicate: (mutation) => mutation.options.scope?.id === project.id,

View File

@ -12,11 +12,12 @@ import * as React from 'react'
export function useSyncRef<T>(value: T): Readonly<React.MutableRefObject<T>> {
const ref = React.useRef(value)
// Update the ref value whenever the provided value changes
// Refs shall never change during the render phase, so we use `useEffect` here.
React.useEffect(() => {
ref.current = value
})
/*
Even though the react core team doesn't recommend setting ref values during the render (it might lead to deoptimizations), the reasoning behind this is:
- We want to make useEventCallback behave the same way as const x = () => {} or useCallback but have a stable reference.
- React components shall be idempotent by default, and we don't see violations here.
*/
ref.current = value
return ref
}

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,7 @@ export interface AssetsTableContextMenuProps {
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId,
) => void
readonly doDelete: (assetId: backendModule.AssetId, forever?: boolean) => Promise<void>
}
/** A context menu for an `AssetsTable`, when no row is selected, or multiple rows
@ -56,7 +57,7 @@ export interface AssetsTableContextMenuProps {
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
const { hidden = false, backend, category, pasteData } = props
const { nodeMapRef, event, rootDirectoryId } = props
const { doCopy, doCut, doPaste } = props
const { doCopy, doCut, doPaste, doDelete } = props
const { user } = authProvider.useFullUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
@ -82,7 +83,10 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
const doDeleteAll = () => {
if (isCloud) {
unsetModal()
dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
for (const key of selectedKeys) {
void doDelete(key, false)
}
} else {
const [firstKey] = selectedKeys
const soleAssetName =
@ -97,7 +101,10 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
}
doDelete={() => {
setSelectedKeys(EMPTY_SET)
dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
for (const key of selectedKeys) {
void doDelete(key, false)
}
}}
/>,
)

View File

@ -271,6 +271,7 @@ export default function DriveBar(props: DriveBarProps) {
null,
(project) => {
setCreatedProjectId(project.projectId)
setIsCreatingProject(false)
},
() => {
setIsCreatingProject(false)

View File

@ -19,7 +19,7 @@ export interface StartModalProps {
/** A modal containing project templates and news. */
export default function StartModal(props: StartModalProps) {
const { createProject: createProjectRaw } = props
const { createProject } = props
const { getText } = textProvider.useText()
return (
@ -39,7 +39,7 @@ export default function StartModal(props: StartModalProps) {
<Samples
createProject={(templateId, templateName) => {
createProjectRaw(templateId, templateName)
createProject(templateId, templateName)
opts.close()
}}
/>

View File

@ -7,8 +7,8 @@ import { IS_DEV_MODE } from 'enso-common/src/detect'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useEnableVersionChecker } from '#/components/Devtools'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useEnableVersionChecker } from '#/providers/EnsoDevtoolsProvider'
import { useText } from '#/providers/TextProvider'
import { Button, ButtonGroup, Dialog, Text } from '#/components/AriaComponents'

View File

@ -50,12 +50,13 @@ export interface DuplicateAssetsModalProps {
readonly nonConflictingFileCount: number
readonly nonConflictingProjectCount: number
readonly doUploadNonConflicting: () => void
readonly doUpdateConflicting: (toUpdate: ConflictingAsset[]) => void
}
/** A modal for creating a new label. */
export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
const { parentKey, parentId, conflictingFiles: conflictingFilesRaw } = props
const { conflictingProjects: conflictingProjectsRaw } = props
const { conflictingProjects: conflictingProjectsRaw, doUpdateConflicting } = props
const { siblingFileNames: siblingFileNamesRaw } = props
const { siblingProjectNames: siblingProjectNamesRaw } = props
const { nonConflictingFileCount, nonConflictingProjectCount, doUploadNonConflicting } = props
@ -125,13 +126,6 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
return title
}
const doUpdate = (toUpdate: ConflictingAsset[]) => {
dispatchAssetEvent({
type: AssetEventType.updateFiles,
files: new Map(toUpdate.map((asset) => [asset.current.id, asset.file])),
})
}
const doRename = (toRename: ConflictingAsset[]) => {
const clonedConflicts = structuredClone(toRename)
for (const conflict of clonedConflicts) {
@ -219,7 +213,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
<ariaComponents.Button
variant="outline"
onPress={() => {
doUpdate([firstConflict])
doUpdateConflicting([firstConflict])
switch (firstConflict.new.type) {
case backendModule.AssetType.file: {
setConflictingFiles((oldConflicts) => oldConflicts.slice(1))
@ -278,7 +272,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
onPress={() => {
unsetModal()
doUploadNonConflicting()
doUpdate([...conflictingFiles, ...conflictingProjects])
doUpdateConflicting([...conflictingFiles, ...conflictingProjects])
}}
>
{count === 1 ? getText('update') : getText('updateAll')}

View File

@ -327,6 +327,7 @@ function DashboardInner(props: DashboardProps) {
{appRunner != null &&
launchedProjects.map((project) => (
<aria.TabPanel
key={project.id}
shouldForceMount
id={project.id}
className="flex min-h-0 grow [&[data-inert]]:hidden"

View File

@ -1,80 +0,0 @@
/** @file The React provider (and associated hooks) for Data Catalog state. */
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as zustand from 'zustand'
// =========================
// === EnsoDevtoolsStore ===
// =========================
/** The state of this zustand store. */
interface EnsoDevtoolsStore {
readonly showVersionChecker: boolean | null
readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void
}
// =======================
// === ProjectsContext ===
// =======================
/** State contained in a `ProjectsContext`. */
export interface ProjectsContextType extends zustand.StoreApi<EnsoDevtoolsStore> {}
const EnsoDevtoolsContext = React.createContext<ProjectsContextType | null>(null)
/** Props for a {@link EnsoDevtoolsProvider}. */
export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren> {}
// ========================
// === ProjectsProvider ===
// ========================
/** A React provider (and associated hooks) for determining whether the current area
* containing the current element is focused. */
export default function EnsoDevtoolsProvider(props: ProjectsProviderProps) {
const { children } = props
const [store] = React.useState(() => {
return zustand.createStore<EnsoDevtoolsStore>((set) => ({
showVersionChecker: false,
setEnableVersionChecker: (showVersionChecker) => {
set({ showVersionChecker })
},
}))
})
return <EnsoDevtoolsContext.Provider value={store}>{children}</EnsoDevtoolsContext.Provider>
}
// ============================
// === useEnsoDevtoolsStore ===
// ============================
/** The Enso devtools store. */
function useEnsoDevtoolsStore() {
const store = React.useContext(EnsoDevtoolsContext)
invariant(store, 'Enso Devtools store can only be used inside an `EnsoDevtoolsProvider`.')
return store
}
// ===============================
// === useEnableVersionChecker ===
// ===============================
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useEnableVersionChecker() {
const store = useEnsoDevtoolsStore()
return zustand.useStore(store, (state) => state.showVersionChecker)
}
// ==================================
// === useSetEnableVersionChecker ===
// ==================================
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useSetEnableVersionChecker() {
const store = useEnsoDevtoolsStore()
return zustand.useStore(store, (state) => state.setEnableVersionChecker)
}

View File

@ -0,0 +1,114 @@
/**
* @file
*
* Feature flags provider.
* Feature flags are used to enable or disable certain features in the application.
*/
import { useMount } from '#/hooks/mountHooks'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import LocalStorage from '#/utilities/LocalStorage'
import { unsafeEntries } from '#/utilities/object'
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import { z } from 'zod'
import { createStore, useStore } from 'zustand'
declare module '#/utilities/LocalStorage' {
/**
* Local storage data structure.
*/
interface LocalStorageData {
readonly featureFlags: z.infer<typeof FEATURE_FLAGS_SCHEMA>
}
}
export const FEATURE_FLAGS_SCHEMA = z.object({
enableMultitabs: z.boolean(),
enableAssetsTableBackgroundRefresh: z.boolean(),
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
assetsTableBackgroundRefreshInterval: z.number().min(100),
})
LocalStorage.registerKey('featureFlags', { schema: FEATURE_FLAGS_SCHEMA })
/**
* Feature flags store.
*/
export interface FeatureFlags {
readonly featureFlags: {
readonly enableMultitabs: boolean
readonly enableAssetsTableBackgroundRefresh: boolean
readonly assetsTableBackgroundRefreshInterval: number
}
readonly setFeatureFlags: <Key extends keyof FeatureFlags['featureFlags']>(
key: Key,
value: FeatureFlags['featureFlags'][Key],
) => void
}
const flagsStore = createStore<FeatureFlags>((set) => ({
featureFlags: {
enableMultitabs: false,
enableAssetsTableBackgroundRefresh: true,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
assetsTableBackgroundRefreshInterval: 3_000,
},
setFeatureFlags: (key, value) => {
set(({ featureFlags }) => ({ featureFlags: { ...featureFlags, [key]: value } }))
},
}))
/**
* Hook to get all feature flags.
*/
export function useFeatureFlags() {
return useStore(flagsStore, (state) => state.featureFlags)
}
/**
* Hook to get a specific feature flag.
*/
export function useFeatureFlag<Key extends keyof FeatureFlags['featureFlags']>(
key: Key,
): FeatureFlags['featureFlags'][Key] {
return useStore(flagsStore, ({ featureFlags }) => featureFlags[key])
}
/**
* Hook to set feature flags.
*/
export function useSetFeatureFlags() {
return useStore(flagsStore, ({ setFeatureFlags }) => setFeatureFlags)
}
/**
* Feature flags provider.
* Gets feature flags from local storage and sets them in the store.
* Also saves feature flags to local storage when they change.
*/
export function FeatureFlagsProvider({ children }: { children: ReactNode }) {
const { localStorage } = useLocalStorage()
const setFeatureFlags = useSetFeatureFlags()
useMount(() => {
const storedFeatureFlags = localStorage.get('featureFlags')
if (storedFeatureFlags) {
for (const [key, value] of unsafeEntries(storedFeatureFlags)) {
setFeatureFlags(key, value)
}
}
})
useEffect(
() =>
flagsStore.subscribe((state, prevState) => {
if (state.featureFlags !== prevState.featureFlags) {
localStorage.set('featureFlags', state.featureFlags)
}
}),
[localStorage],
)
return <>{children}</>
}

View File

@ -17,7 +17,6 @@ export interface AssetTreeNodeData
| 'directoryId'
| 'directoryKey'
| 'initialAssetEvents'
| 'isExpanded'
| 'item'
| 'key'
| 'path'
@ -52,7 +51,6 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
* This must never change, otherwise the component's state is lost when receiving the real id
* from the backend. */
public readonly key: Item['id'] = item.id,
public readonly isExpanded = false,
public readonly createdAt = new Date(),
) {
this.type = item.type
@ -72,7 +70,7 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
directoryId: backendModule.DirectoryId,
depth: number,
path: string,
initialAssetEvents: readonly assetEvent.AssetEvent[] | null,
initialAssetEvents: readonly assetEvent.AssetEvent[] | null = null,
key: Asset['id'] = asset.id,
): AnyAssetTreeNode {
return new AssetTreeNode(
@ -115,7 +113,6 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
update.path ?? this.path,
update.initialAssetEvents ?? this.initialAssetEvents,
update.key ?? this.key,
update.isExpanded ?? this.isExpanded,
update.createdAt ?? this.createdAt,
).asUnion()
}
@ -184,7 +181,7 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
preorderTraversal(
preprocess: ((tree: AnyAssetTreeNode[]) => AnyAssetTreeNode[]) | null = null,
): AnyAssetTreeNode[] {
const children = !this.isExpanded ? [] : this.children ?? []
const children = this.children ?? []
return (preprocess?.(children) ?? children).flatMap((node) =>
node.children == null ? [node] : [node, ...node.preorderTraversal(preprocess)],
)

View File

@ -459,6 +459,14 @@
"organizationInviteErrorSuffix": "' is inviting you.",
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at",
"enableMultitabs": "Enable Multi-Tabs",
"enableMultitabsDescription": "Open multiple projects at the same time.",
"enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
"enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.",
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
"deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'",
"deleteSelectedAssetsActionText": "delete $0 selected items",
@ -856,10 +864,11 @@
"shareFullFeatureDescription": "Share unlimited assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.",
"shareFullPaywallMessage": "You can only share assets with a single user group. Upgrade to share assets with multiple user groups and users.",
"paywallDevtoolsButtonLabel": "Open Enso Devtools",
"paywallDevtoolsPopoverHeading": "Enso Devtools",
"paywallDevtoolsPlanSelectSubtitle": "User Plan",
"paywallDevtoolsPaywallFeaturesToggles": "Paywall Features",
"ensoDevtoolsButtonLabel": "Open Enso Devtools",
"ensoDevtoolsPopoverHeading": "Enso Devtools",
"ensoDevtoolsPlanSelectSubtitle": "User Plan",
"ensoDevtoolsPaywallFeaturesToggles": "Paywall Features",
"ensoDevtoolsFeatureFlags": "Feature Flags",
"setupEnso": "Set up Enso",
"termsAndConditions": "Terms and Conditions",

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["DOM", "es2023"],
"allowJs": false,
"checkJs": false,
"skipLibCheck": false