From 3448bb3312fbb696e0d2255f8ba45d5b07189f11 Mon Sep 17 00:00:00 2001 From: Sergey Garin Date: Wed, 7 Aug 2024 18:54:21 +0300 Subject: [PATCH 01/17] Refetch Interval + Feature Toggles --- app/dashboard/src/App.tsx | 10 +- .../AriaComponents/Dialog/DialogTrigger.tsx | 49 +- .../components/AriaComponents/Dialog/types.ts | 3 - .../components/AriaComponents/Form/Form.tsx | 107 +- .../AriaComponents/Form/components/Field.tsx | 2 +- .../AriaComponents/Form/components/index.ts | 1 + .../AriaComponents/Form/components/types.ts | 32 +- .../Form/components/useField.ts | 1 - .../AriaComponents/Form/components/useForm.ts | 47 +- .../components/AriaComponents/Form/types.ts | 10 +- .../AriaComponents/Inputs/variants.ts | 2 +- .../AriaComponents/Switch/Switch.tsx | 168 +++ .../components/AriaComponents/Switch/index.ts | 6 + .../AriaComponents/Tooltip/Tooltip.tsx | 2 +- .../src/components/AriaComponents/index.ts | 1 + .../src/components/Devtools/EnsoDevtools.tsx | 402 ++++--- .../Devtools/EnsoDevtoolsProvider.tsx | 73 ++ .../src/components/Devtools/index.ts | 1 + .../src/components/dashboard/AssetRow.tsx | 84 +- .../dashboard/DatalinkNameColumn.tsx | 81 +- .../dashboard/DirectoryNameColumn.tsx | 72 +- .../components/dashboard/FileNameColumn.tsx | 80 +- .../dashboard/PermissionSelector.tsx | 16 +- .../dashboard/ProjectNameColumn.tsx | 190 +-- .../components/dashboard/SecretNameColumn.tsx | 74 +- app/dashboard/src/hooks/intersectionHooks.ts | 8 +- app/dashboard/src/hooks/projectHooks.ts | 13 +- app/dashboard/src/hooks/syncRefHooks.ts | 4 +- app/dashboard/src/layouts/AssetsTable.tsx | 1068 +++++++++-------- app/dashboard/src/layouts/DriveBar.tsx | 2 + app/dashboard/src/layouts/StartModal.tsx | 4 +- app/dashboard/src/layouts/VersionChecker.tsx | 2 +- .../src/modals/DuplicateAssetsModal.tsx | 14 +- .../src/pages/dashboard/Dashboard.tsx | 1 + .../src/providers/EnsoDevtoolsProvider.tsx | 80 -- .../src/providers/FeatureFlagsProvider.tsx | 118 ++ app/dashboard/src/utilities/AssetTreeNode.ts | 7 +- app/ide-desktop/common/src/text/english.json | 17 +- app/ide-desktop/common/tsconfig.json | 1 + 39 files changed, 1404 insertions(+), 1449 deletions(-) create mode 100644 app/dashboard/src/components/AriaComponents/Switch/Switch.tsx create mode 100644 app/dashboard/src/components/AriaComponents/Switch/index.ts create mode 100644 app/dashboard/src/components/Devtools/EnsoDevtoolsProvider.tsx delete mode 100644 app/dashboard/src/providers/EnsoDevtoolsProvider.tsx create mode 100644 app/dashboard/src/providers/FeatureFlagsProvider.tsx diff --git a/app/dashboard/src/App.tsx b/app/dashboard/src/App.tsx index d4b0f8a72cb9..3e351c27d359 100644 --- a/app/dashboard/src/App.tsx +++ b/app/dashboard/src/App.tsx @@ -49,7 +49,6 @@ import * as inputBindingsModule from '#/configurations/inputBindings' import AuthProvider, * as authProvider from '#/providers/AuthProvider' import BackendProvider 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' @@ -93,6 +92,7 @@ import LocalStorage from '#/utilities/LocalStorage' import * as object from '#/utilities/object' import { useInitAuthService } from '#/authentication/service' +import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider' // ============================ // === Global configuration === @@ -479,7 +479,7 @@ function AppRouter(props: AppRouterProps) { ) return ( - + - + + + )} @@ -513,6 +515,6 @@ function AppRouter(props: AppRouterProps) { - + ) } diff --git a/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx b/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx index 6f2d91e4e1dd..f69c25dfae82 100644 --- a/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx +++ b/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx @@ -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 =
+/** + * 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 { + /** + * 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 ( - {children} + {trigger} + + {/* We're using AnimatePresence here to animate the dialog in and out. */} + + {isOpened && ( + + {typeof dialog === 'function' ? dialog(renderProps) : dialog} + + )} + ) } diff --git a/app/dashboard/src/components/AriaComponents/Dialog/types.ts b/app/dashboard/src/components/AriaComponents/Dialog/types.ts index 7e7b7f2cbcc0..127d3c5443eb 100644 --- a/app/dashboard/src/components/AriaComponents/Dialog/types.ts +++ b/app/dashboard/src/components/AriaComponents/Dialog/types.ts @@ -10,6 +10,3 @@ export interface DialogProps extends aria.DialogProps { readonly modalProps?: Pick readonly testId?: string } - -/** The props for the DialogTrigger component. */ -export interface DialogTriggerProps extends aria.DialogTriggerProps {} diff --git a/app/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/dashboard/src/components/AriaComponents/Form/Form.tsx index f03489fe2f8e..c7aab2590018 100644 --- a/app/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -72,18 +72,12 @@ export const Form = React.forwardRef(function Form< formOptions.defaultValues = defaultValues } - const innerForm = components.useForm( - form ?? { - shouldFocusError: true, - schema, - ...formOptions, - }, - ) - - 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,52 +134,13 @@ export const Form = React.forwardRef(function Form< { isDisabled: canSubmitOffline }, ) - const { - formState, - clearErrors, - getValues, - setValue, - setError, - register, - unregister, - setFocus, - reset, - control, - } = innerForm - - const formStateRenderProps: types.FormStateRenderProps = - { - formState, - register: (name, options) => { - const registered = register(name, options) - - const result: types.UseFormRegisterReturn = { - ...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]) => { @@ -209,41 +164,40 @@ export const Form = React.forwardRef(function Form< } }} className={base} - style={typeof style === 'function' ? style(formStateRenderProps) : style} + style={typeof style === 'function' ? style(innerForm) : style} noValidate data-testid={testId} {...formProps} > - {typeof children === 'function' ? children(formStateRenderProps) : children} + {typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children} ) -}) as unknown as Mutable< - Pick< - typeof components, - | 'FIELD_STYLES' - | 'Field' - | 'FormError' - | 'Reset' - | 'schema' - | 'Submit' - | 'useField' - | 'useForm' - | 'useFormSchema' - > -> & - (< - Schema extends components.TSchema, - TFieldValues extends components.FieldValues, - TTransformedValues extends components.FieldValues | undefined = undefined, - >( - props: React.RefAttributes & - types.FormProps, - // eslint-disable-next-line no-restricted-syntax - ) => React.JSX.Element) +}) as unknown as (< + Schema extends components.TSchema, + TFieldValues extends components.FieldValues, + TTransformedValues extends components.FieldValues | undefined = undefined, +>( + props: React.RefAttributes & + types.FormProps, + // eslint-disable-next-line no-restricted-syntax +) => 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 @@ -253,4 +207,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 diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx index 31429f77f7d5..58ca10713ed2 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx @@ -21,7 +21,7 @@ export interface FieldComponentProps extends VariantProps, readonly 'data-testid'?: string | undefined readonly name: string // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly form?: types.FormInstance + readonly form?: types.FormInstance | undefined readonly isInvalid?: boolean | undefined readonly className?: string | undefined readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode) diff --git a/app/dashboard/src/components/AriaComponents/Form/components/index.ts b/app/dashboard/src/components/AriaComponents/Form/components/index.ts index ae7aa01ceda6..497ad213673e 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/index.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/index.ts @@ -3,6 +3,7 @@ * * Barrel file for form components. */ +export { Controller } from 'react-hook-form' export * from './Field' export * from './FormError' export * from './Reset' diff --git a/app/dashboard/src/components/AriaComponents/Form/components/types.ts b/app/dashboard/src/components/AriaComponents/Form/components/types.ts index 46e3ac542d86..d59f27c2c69b 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/types.ts @@ -41,6 +41,34 @@ export interface UseFormProps Schema) } +/** + * Register function for a form field. + */ +export type UseFormRegister> = < + TFieldName extends FieldPath = FieldPath, +>( + name: TFieldName, + options?: reactHookForm.RegisterOptions, + // eslint-disable-next-line no-restricted-syntax +) => UseFormRegisterReturn + +/** + * UseFormRegister return type. + */ +export interface UseFormRegisterReturn< + Schema extends TSchema, + TFieldValues extends FieldValues, + TFieldName extends FieldPath = FieldPath, +> extends Omit, 'onBlur' | 'onChange'> { + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + readonly onChange: (value: Value) => Promise + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + readonly onBlur: (value: Value) => Promise + readonly isDisabled?: boolean + readonly isRequired?: boolean + readonly isInvalid?: boolean +} + /** * Return type of the useForm hook. * @alias reactHookForm.UseFormReturn @@ -49,7 +77,9 @@ export interface UseFormReturn< Schema extends TSchema, TFieldValues extends FieldValues, TTransformedValues extends Record | undefined = undefined, -> extends reactHookForm.UseFormReturn {} +> extends reactHookForm.UseFormReturn { + readonly register: UseFormRegister +} /** * Form state type. diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts index bbd8714da1d5..1c5c92d86b8e 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts @@ -52,7 +52,6 @@ export function useField< const { field, fieldState, formState } = reactHookForm.useController({ name, - control: formInstance.control, disabled: isDisabled, ...(defaultValue != null ? { defaultValue } : {}), }) diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts index 656e9bdc3c1b..a491dcd4bed9 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts @@ -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). @@ -54,10 +66,37 @@ export function useForm< const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema - return reactHookForm.useForm({ + const formInstance = reactHookForm.useForm({ ...options, - resolver: zodResolver.zodResolver(computedSchema, { async: true }), + resolver: zodResolver.zodResolver(computedSchema), }) + + const register: types.UseFormRegister = (name, opts) => { + const registered = formInstance.register(name, opts) + + const onChange: types.UseFormRegisterReturn['onChange'] = (value) => + registered.onChange(mapValueOnEvent(value)) + + const onBlur: types.UseFormRegisterReturn['onBlur'] = (value) => + registered.onBlur(mapValueOnEvent(value)) + + const result: types.UseFormRegisterReturn = { + ...registered, + ...(registered.disabled != null ? { isDisabled: registered.disabled } : {}), + ...(registered.required != null ? { isRequired: registered.required } : {}), + isInvalid: !!formInstance.formState.errors[name], + onChange, + onBlur, + } + + return result + } + + return { + ...formInstance, + control: { ...formInstance.control, register }, + register, + } satisfies types.UseFormReturn } } diff --git a/app/dashboard/src/components/AriaComponents/Form/types.ts b/app/dashboard/src/components/AriaComponents/Form/types.ts index 00d79f59d308..90458c1e5e8b 100644 --- a/app/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/types.ts @@ -55,18 +55,22 @@ interface BaseFormProps< readonly style?: | React.CSSProperties | (( - props: FormStateRenderProps, + props: components.UseFormReturn, ) => React.CSSProperties) readonly children: | React.ReactNode - | ((props: FormStateRenderProps) => React.ReactNode) + | (( + props: components.UseFormReturn & { + readonly form: components.UseFormReturn + }, + ) => React.ReactNode) readonly formRef?: React.MutableRefObject< components.UseFormReturn > readonly className?: | string - | ((props: FormStateRenderProps) => string) + | ((props: components.UseFormReturn) => string) readonly onSubmitFailed?: (error: unknown) => Promise | void readonly onSubmitSuccess?: () => Promise | void diff --git a/app/dashboard/src/components/AriaComponents/Inputs/variants.ts b/app/dashboard/src/components/AriaComponents/Inputs/variants.ts index 83b9cdf47bfd..08179c94cadf 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/variants.ts +++ b/app/dashboard/src/components/AriaComponents/Inputs/variants.ts @@ -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', }, }, diff --git a/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx b/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx new file mode 100644 index 000000000000..96c91a1e632c --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx @@ -0,0 +1,168 @@ +/** + * @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 type { CSSProperties, ForwardedRef, ReactElement, RefAttributes } from 'react' +import { forwardRef, useRef } from 'react' +import { tv, type VariantProps } from 'tailwind-variants' +import { + Form, + type FieldPath, + type FieldProps, + type FieldStateProps, + type FieldValues, + type TSchema, +} from '../Form' +import { TEXT_STYLE } from '../Text' + +/** + * Props for the {@Switch} component. + */ +export interface SwitchProps< + Schema extends TSchema, + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TTransformedValues extends FieldValues | undefined = undefined, +> extends FieldStateProps< + Omit & { value: boolean }, + Schema, + TFieldValues, + TFieldName, + TTransformedValues + >, + FieldProps, + Omit, '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, + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TTransformedValues extends FieldValues | undefined = undefined, +>( + props: SwitchProps, + ref: ForwardedRef, +) { + const { + label, + isDisabled = false, + isRequired = false, + defaultValue, + className, + name, + form, + description, + error, + size, + ...ariaSwitchProps + } = props + + const switchRef = useRef(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 ( + + ()(ariaSwitchProps, fieldProps, { + defaultSelected: field.value, + className: switchStyles(), + })} + > +
+ +
+ +
{label}
+
+
+ ) +}) as < + Schema extends TSchema, + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TTransformedValues extends FieldValues | undefined = undefined, +>( + props: RefAttributes & + SwitchProps, +) => ReactElement diff --git a/app/dashboard/src/components/AriaComponents/Switch/index.ts b/app/dashboard/src/components/AriaComponents/Switch/index.ts new file mode 100644 index 000000000000..9fcf650eab47 --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Switch/index.ts @@ -0,0 +1,6 @@ +/** + * @file + * + * Barrel file for Switch component. + */ +export * from './Switch' diff --git a/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx b/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx index c81be71df98b..6e18983a3ff4 100644 --- a/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx @@ -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: '', diff --git a/app/dashboard/src/components/AriaComponents/index.ts b/app/dashboard/src/components/AriaComponents/index.ts index eae16324c1d6..b7b1be5f77e3 100644 --- a/app/dashboard/src/components/AriaComponents/index.ts +++ b/app/dashboard/src/components/AriaComponents/index.ts @@ -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' diff --git a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx index 5e535ba36ee2..700246ab9462 100644 --- a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx +++ b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx @@ -17,231 +17,233 @@ 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 aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' import Portal from '#/components/Portal' +import { + FEATURE_FLAGS_SCHEMA, + useFeatureFlags, + useSetFeatureFlags, +} from '#/providers/FeatureFlagsProvider' import * as backend from '#/services/Backend' -/** - * Configuration for a paywall feature. - */ -export interface PaywallDevtoolsFeatureConfiguration { - readonly isForceEnabled: boolean | null -} - -const PaywallDevtoolsContext = React.createContext<{ - features: Record -}>({ - 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 [features, setFeatures] = React.useState< - Record - >({ - 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 ( - - {children} - - - - - - - - {getText('paywallDevtoolsPopoverHeading')} - - - - - {session?.type === UserSessionType.full && ( - <> - - {getText('paywallDevtoolsPlanSelectSubtitle')} - - - schema.object({ plan: schema.string() })} - defaultValues={{ plan: session.user.plan ?? 'free' }} - > - {({ form }) => ( - <> - { - queryClient.setQueryData(authQueryKey, { - ...session, - user: { ...session.user, plan: value }, - }) - }} - > - - - - - - - - queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => { - form.reset() - }) - } - > - {getText('reset')} - - - )} - - - - - {/* eslint-disable-next-line no-restricted-syntax */} - - Open setup page - - - - - )} - - - {getText('productionOnlyFeatures')} - -
- -
- -
- - - {getText('enableVersionChecker')} - -
- - - {getText('enableVersionCheckerDescription')} + + + + + + + {getText('ensoDevtoolsPopoverHeading')} + + + + + {session?.type === UserSessionType.full && ( + <> + + {getText('ensoDevtoolsPlanSelectSubtitle')} -
- - - - - {getText('paywallDevtoolsPaywallFeaturesToggles')} - - -
- {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 ( -
- schema.object({ plan: schema.nativeEnum(backend.Plan) })} + defaultValues={{ plan: session.user.plan ?? backend.Plan.free }} + > + {({ form }) => ( + <> + { - onConfigurationChange(featureName, { - isForceEnabled: value, + queryClient.setQueryData(authQueryKey, { + ...session, + user: { ...session.user, plan: value }, }) }} > -
- -
- - {getText(label)} -
- - - {getText(descriptionTextId)} - + + + + + + + + queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => { + form.reset() + }) + } + > + {getText('reset')} + + + )} + + + + + {/* eslint-disable-next-line no-restricted-syntax */} + + Open setup page + + + + + )} + + + {getText('productionOnlyFeatures')} + + + z.object({ enableVersionChecker: z.boolean() })} + defaultValues={{ enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE }} + > + {({ form }) => ( + { + setEnableVersionChecker(value) + }} + /> + )} + + + + + + {getText('ensoDevtoolsFeatureFlags')} + + + {(form) => ( + <> + { + setFeatureFlags('enableMultitabs', value) + }} + /> + +
+ { + setFeatureFlags('enableAssetsTableBackgroundRefresh', value) + }} + /> + { + setFeatureFlags( + 'assetsTableBackgroundRefreshInterval', + event.target.valueAsNumber, + ) + }} + />
- ) - })} -
- - - - + + )} + + + + + + + {getText('ensoDevtoolsPaywallFeaturesToggles')} + + + + 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 ( + { + setFeature(featureName, value) + }} + /> + ) + })} + + + + ) } - -/** - * A hook that provides access to the paywall devtools. - */ -export function usePaywallDevtools() { - const context = React.useContext(PaywallDevtoolsContext) - - React.useDebugValue(context) - - return context -} diff --git a/app/dashboard/src/components/Devtools/EnsoDevtoolsProvider.tsx b/app/dashboard/src/components/Devtools/EnsoDevtoolsProvider.tsx new file mode 100644 index 000000000000..ba6829dc60f3 --- /dev/null +++ b/app/dashboard/src/components/Devtools/EnsoDevtoolsProvider.tsx @@ -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 + readonly setPaywallFeature: (feature: PaywallFeatureName, isForceEnabled: boolean | null) => void + readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void +} + +const ensoDevtoolsStore = zustand.createStore((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, + })) +} diff --git a/app/dashboard/src/components/Devtools/index.ts b/app/dashboard/src/components/Devtools/index.ts index 70ff0a4e0f42..ea18be3774e5 100644 --- a/app/dashboard/src/components/Devtools/index.ts +++ b/app/dashboard/src/components/Devtools/index.ts @@ -5,4 +5,5 @@ */ export * from './EnsoDevtools' +export * from './EnsoDevtoolsProvider' export * from './ReactQueryDevtools' diff --git a/app/dashboard/src/components/dashboard/AssetRow.tsx b/app/dashboard/src/components/dashboard/AssetRow.tsx index 7e70ef335dfc..9d8285e26400 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -58,7 +58,7 @@ import { useQuery } from '@tanstack/react-query' // ================= /** 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 @@ -285,6 +285,7 @@ export default function AssetRow(props: AssetRowProps) { // 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, @@ -292,7 +293,9 @@ export default function AssetRow(props: AssetRowProps) { key: item.key, item: newAsset, }) + setAsset(newAsset) + await updateAsset([ asset.id, { parentDirectoryId: newParentId ?? rootDirectoryId, description: null }, @@ -440,21 +443,11 @@ export default function AssetRow(props: AssetRowProps) { break } default: { - return + break } } } 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) @@ -674,18 +667,18 @@ export default function AssetRow(props: AssetRowProps) { } case AssetEventType.deleteLabel: { setAsset((oldAsset) => { - // The IIFE is required to prevent TypeScript from narrowing this value. - let found = (() => false)() - const labels = - oldAsset.labels?.filter((label) => { - if (label === event.labelName) { - found = true - return false - } else { - return true - } - }) ?? null - return found ? object.merge(oldAsset, { labels }) : oldAsset + const oldLabels = oldAsset.labels ?? [] + const labels: backendModule.LabelName[] = [] + + for (const label of oldLabels) { + if (label !== event.labelName) { + labels.push(label) + } + } + + return oldLabels.length !== labels.length ? + object.merge(oldAsset, { labels }) + : oldAsset }) break } @@ -695,6 +688,8 @@ export default function AssetRow(props: AssetRowProps) { } break } + default: + break } } }, item.initialAssetEvents) @@ -749,18 +744,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() } @@ -784,7 +784,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) => { @@ -829,7 +829,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`. @@ -867,10 +867,10 @@ export default function AssetRow(props: AssetRowProps) { if (state.category !== Category.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 && @@ -879,7 +879,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) @@ -892,7 +892,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, diff --git a/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx b/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx index f9376efd54ab..a1807acd8f12 100644 --- a/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx @@ -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} diff --git a/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx b/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx index 75def3d91baa..fb4adf4f091b 100644 --- a/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx @@ -14,11 +14,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' @@ -32,7 +27,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 === @@ -47,21 +41,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) => { @@ -93,59 +88,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) @@ -187,7 +129,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { isExpanded && 'rotate-90', )} onPress={() => { - doToggleDirectoryExpansion(asset.id, item.key, asset.title) + doToggleDirectoryExpansion(asset.id, item.key) }} /> @@ -195,7 +137,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) => diff --git a/app/dashboard/src/components/dashboard/FileNameColumn.tsx b/app/dashboard/src/components/dashboard/FileNameColumn.tsx index f40aeb038a48..068412ae1abe 100644 --- a/app/dashboard/src/components/dashboard/FileNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/FileNameColumn.tsx @@ -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) { item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children) } diff --git a/app/dashboard/src/components/dashboard/PermissionSelector.tsx b/app/dashboard/src/components/dashboard/PermissionSelector.tsx index a2748a01e9d7..cedf193e2883 100644 --- a/app/dashboard/src/components/dashboard/PermissionSelector.tsx +++ b/app/dashboard/src/components/dashboard/PermissionSelector.tsx @@ -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} diff --git a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx index 0286c8b8ed84..347715f8569d 100644 --- a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -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.useNonPartialUserSession() 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', )} diff --git a/app/dashboard/src/components/dashboard/SecretNameColumn.tsx b/app/dashboard/src/components/dashboard/SecretNameColumn.tsx index 3170bed6b7f2..a4e924799751 100644 --- a/app/dashboard/src/components/dashboard/SecretNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/SecretNameColumn.tsx @@ -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) diff --git a/app/dashboard/src/hooks/intersectionHooks.ts b/app/dashboard/src/hooks/intersectionHooks.ts index e682d9b293e7..845f019f9724 100644 --- a/app/dashboard/src/hooks/intersectionHooks.ts +++ b/app/dashboard/src/hooks/intersectionHooks.ts @@ -48,7 +48,9 @@ export function useIntersectionRatio( 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( 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(() => { diff --git a/app/dashboard/src/hooks/projectHooks.ts b/app/dashboard/src/hooks/projectHooks.ts index bcedbcd75168..b67cc3e5a122 100644 --- a/app/dashboard/src/hooks/projectHooks.ts +++ b/app/dashboard/src/hooks/projectHooks.ts @@ -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' @@ -230,11 +231,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 sametime, 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, diff --git a/app/dashboard/src/hooks/syncRefHooks.ts b/app/dashboard/src/hooks/syncRefHooks.ts index 9472484c0e90..c68cee8891f6 100644 --- a/app/dashboard/src/hooks/syncRefHooks.ts +++ b/app/dashboard/src/hooks/syncRefHooks.ts @@ -14,9 +14,7 @@ export function useSyncRef(value: T): Readonly> { // 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 - }) + ref.current = value return ref } diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index e96c2bbb890f..51627c1a6e01 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -1,7 +1,7 @@ /** @file Table displaying a list of projects. */ import * as React from 'react' -import { useMutation } from '@tanstack/react-query' +import { queryOptions, useMutation, useQueries, useQueryClient } from '@tanstack/react-query' import * as toast from 'react-toastify' import * as z from 'zod' @@ -10,7 +10,7 @@ import DropFilesImage from '#/assets/drop_files.svg' import * as mimeTypes from '#/data/mimeTypes' import * as autoScrollHooks from '#/hooks/autoScrollHooks' -import { backendMutationOptions, useBackendQuery, useListTags } from '#/hooks/backendHooks' +import { backendMutationOptions, useListTags } from '#/hooks/backendHooks' import * as intersectionHooks from '#/hooks/intersectionHooks' import * as projectHooks from '#/hooks/projectHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' @@ -31,7 +31,6 @@ import * as navigator2DProvider from '#/providers/Navigator2DProvider' import * as projectsProvider from '#/providers/ProjectsProvider' import * as textProvider from '#/providers/TextProvider' -import type * as assetEvent from '#/events/assetEvent' import AssetEventType from '#/events/AssetEventType' import type * as assetListEvent from '#/events/assetListEvent' import AssetListEventType from '#/events/AssetListEventType' @@ -63,9 +62,11 @@ import UpsertSecretModal from '#/modals/UpsertSecretModal' import type Backend from '#/services/Backend' import * as backendModule from '#/services/Backend' -import LocalBackend from '#/services/LocalBackend' +import LocalBackend, * as localBackendModule from '#/services/LocalBackend' +import * as projectManager from '#/services/ProjectManager' -import * as array from '#/utilities/array' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useFeatureFlag } from '#/providers/FeatureFlagsProvider' import type * as assetQuery from '#/utilities/AssetQuery' import AssetQuery from '#/utilities/AssetQuery' import type * as assetTreeNode from '#/utilities/AssetTreeNode' @@ -194,96 +195,6 @@ const SUGGESTIONS_FOR_NEGATIVE_TYPE: assetSearchBar.Suggestion[] = [ }, ] -// =================================== -// === insertAssetTreeNodeChildren === -// =================================== - -/** Return a directory, with new children added into its list of children. - * All children MUST have the same asset type. */ -function insertAssetTreeNodeChildren( - item: assetTreeNode.AnyAssetTreeNode, - children: readonly backendModule.AnyAsset[], - directoryKey: backendModule.DirectoryId, - directoryId: backendModule.DirectoryId, - getInitialAssetEvents: (id: backendModule.AssetId) => readonly assetEvent.AssetEvent[] | null, -): assetTreeNode.AnyAssetTreeNode { - const depth = item.depth + 1 - const typeOrder = children[0] != null ? backendModule.ASSET_TYPE_ORDER[children[0].type] : 0 - const nodes = (item.children ?? []).filter( - (node) => node.item.type !== backendModule.AssetType.specialEmpty, - ) - const nodesToInsert = children.map((asset) => - AssetTreeNode.fromAsset( - asset, - directoryKey, - directoryId, - depth, - `${item.path}/${asset.title}`, - getInitialAssetEvents(asset.id), - ), - ) - const newNodes = array.splicedBefore( - nodes, - nodesToInsert, - (innerItem) => backendModule.ASSET_TYPE_ORDER[innerItem.item.type] >= typeOrder, - ) - return item.with({ children: newNodes }) -} - -/** Return a directory, with new children added into its list of children. - * The children MAY be of different asset types. */ -function insertArbitraryAssetTreeNodeChildren( - item: assetTreeNode.AnyAssetTreeNode, - children: backendModule.AnyAsset[], - directoryKey: backendModule.DirectoryId, - directoryId: backendModule.DirectoryId, - getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null, - getInitialAssetEvents: ( - id: backendModule.AssetId, - ) => readonly assetEvent.AssetEvent[] | null = () => null, -): assetTreeNode.AnyAssetTreeNode { - const depth = item.depth + 1 - const nodes = (item.children ?? []).filter( - (node) => node.item.type !== backendModule.AssetType.specialEmpty, - ) - const byType: Readonly> = { - [backendModule.AssetType.directory]: [], - [backendModule.AssetType.project]: [], - [backendModule.AssetType.file]: [], - [backendModule.AssetType.datalink]: [], - [backendModule.AssetType.secret]: [], - [backendModule.AssetType.specialLoading]: [], - [backendModule.AssetType.specialEmpty]: [], - } - for (const child of children) { - byType[child.type].push(child) - } - let newNodes = nodes - for (const childrenOfSpecificType of Object.values(byType)) { - const firstChild = childrenOfSpecificType[0] - if (firstChild) { - const typeOrder = backendModule.ASSET_TYPE_ORDER[firstChild.type] - const nodesToInsert = childrenOfSpecificType.map((asset) => - AssetTreeNode.fromAsset( - asset, - directoryKey, - directoryId, - depth, - `${item.path}/${asset.title}`, - getInitialAssetEvents(asset.id), - getKey?.(asset) ?? asset.id, - ), - ) - newNodes = array.splicedBefore( - newNodes, - nodesToInsert, - (innerItem) => backendModule.ASSET_TYPE_ORDER[innerItem.item.type] >= typeOrder, - ) - } - } - return newNodes === nodes ? item : item.with({ children: newNodes }) -} - // ========================= // === DragSelectionInfo === // ========================= @@ -314,6 +225,7 @@ const CATEGORY_TO_FILTER_BY: Readonly readonly visibilities: ReadonlyMap readonly category: Category @@ -332,7 +244,6 @@ export interface AssetsTableState { readonly doToggleDirectoryExpansion: ( directoryId: backendModule.DirectoryId, key: backendModule.DirectoryId, - title?: string | null, override?: boolean, ) => void readonly doCopy: () => void @@ -385,7 +296,7 @@ export default function AssetsTable(props: AssetsTableProps) { const doOpenProject = projectHooks.useOpenProject() const setCanDownload = useSetCanDownload() - const { user } = authProvider.useNonPartialUserSession() + const { user } = authProvider.useFullUserSession() const backend = backendProvider.useBackend(category) const labels = useListTags(backend) const { setModal, unsetModal } = modalProvider.useSetModal() @@ -397,10 +308,9 @@ export default function AssetsTable(props: AssetsTableProps) { const previousCategoryRef = React.useRef(category) const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() - const [initialized, setInitialized] = React.useState(false) - const initializedRef = React.useRef(initialized) - initializedRef.current = initialized + const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS) + const [sortInfo, setSortInfo] = React.useState | null>(null) const driveStore = useDriveStore() @@ -412,42 +322,249 @@ export default function AssetsTable(props: AssetsTableProps) { const [pasteData, setPasteData] = React.useState > | null>(null) - const [, setQueuedAssetEvents] = React.useState([]) const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName) const rootDirectoryId = React.useMemo( () => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''), [backend, user], ) - const [assetTree, setAssetTree] = React.useState(() => { - const rootParentDirectoryId = backendModule.DirectoryId('') - return AssetTreeNode.fromAsset( - backendModule.createRootDirectoryAsset(rootDirectoryId), + + const rootParentDirectoryId = backendModule.DirectoryId('') + const rootDirectory = React.useMemo( + () => backendModule.createRootDirectoryAsset(rootDirectoryId), + [rootDirectoryId], + ) + + const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh') + const assetsTableBackgroundRefreshInterval = useFeatureFlag( + 'assetsTableBackgroundRefreshInterval', + ) + /** + * The expanded directories in the asset tree. + */ + const [expandedDirectoryIds, setExpandedDirectoryIds] = React.useState< + backendModule.DirectoryId[] + >(() => [rootDirectory.id]) + + const expandedDirectoryIdsSet = React.useMemo( + () => new Set(expandedDirectoryIds), + [expandedDirectoryIds], + ) + + const createProjectMutation = useMutation( + backendMutationOptions(backend, 'createProject', { + meta: { invalidates: [['listDirectory', backend.type]] }, + }), + ) + const duplicateProjectMutation = useMutation( + backendMutationOptions(backend, 'duplicateProject', { + meta: { invalidates: [['listDirectory', backend.type]] }, + }), + ) + const createDirectoryMutation = useMutation( + backendMutationOptions(backend, 'createDirectory', { + meta: { invalidates: [['listDirectory', backend.type]] }, + }), + ) + const createSecretMutation = useMutation( + backendMutationOptions(backend, 'createSecret', { + meta: { invalidates: [['listDirectory', backend.type]] }, + }), + ) + const updateSecretMutation = useMutation( + backendMutationOptions(backend, 'updateSecret', { + meta: { invalidates: [['listDirectory', backend.type]] }, + }), + ) + const createDatalinkMutation = useMutation( + backendMutationOptions(backend, 'createDatalink', { + meta: { invalidates: [['listDirectory', backend.type]] }, + }), + ) + const uploadFileMutation = useMutation( + backendMutationOptions(backend, 'uploadFile', { + meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, + }), + ) + const getProjectDetailsMutation = useMutation( + backendMutationOptions(backend, 'getProjectDetails'), + ) + + const directories = useQueries({ + // we fetch only expanded directories + // this is an optimization technique to reduce the amount of data to fetch + queries: React.useMemo( + () => + expandedDirectoryIds.map((directoryId) => + queryOptions({ + queryKey: [ + 'listDirectory', + backend.type, + directoryId, + { + parentId: directoryId, + labels: null, + filterBy: CATEGORY_TO_FILTER_BY[category], + recentProjects: category === Category.recent, + }, + ] as const, + queryFn: async ({ queryKey: [, , parentId, params] }) => ({ + parentId, + children: await backend.listDirectory(params, parentId), + }), + + refetchInterval: + enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false, + refetchOnMount: 'always', + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + + enabled: !hidden, + meta: { persist: false }, + }), + ), + [ + hidden, + backend, + category, + expandedDirectoryIds, + assetsTableBackgroundRefreshInterval, + enableAssetsTableBackgroundRefresh, + ], + ), + combine: (results) => { + const rootQuery = results.find((directory) => directory.data?.parentId === rootDirectory.id) + + return { + rootDirectory: { + isFetching: rootQuery?.isFetching ?? true, + isLoading: rootQuery?.isLoading ?? true, + data: rootQuery?.data, + }, + directories: results.map((res) => ({ + isFetching: res.isFetching, + isLoading: res.isLoading, + data: res.data, + })), + } + }, + }) + + /** + * ReturnType of the query function for the listDirectory query. + */ + type ListDirectoryQueryDataType = (typeof directories)['rootDirectory']['data'] + + const rootDirectoryContent = directories.rootDirectory.data?.children + const isLoading = directories.rootDirectory.isLoading + + const assetTree = React.useMemo(() => { + // If the root directory is not loaded, then we cannot render the tree. + // Return null, and wait for the root directory to load. + if (rootDirectoryContent == null) { + // eslint-disable-next-line no-restricted-syntax + return AssetTreeNode.fromAsset( + backendModule.createRootDirectoryAsset(rootDirectoryId), + rootParentDirectoryId, + rootParentDirectoryId, + -1, + backend.rootPath, + null, + ) + } + + const rootId = rootDirectory.id + + const children = rootDirectoryContent.map((content) => { + /** + * Recursively build assets tree. If a child is a directory, we search for it is content in the loaded + * data. If it is loaded, we append that data to the asset node and do the same for the children + */ + const appendChildrenRecursively = (node: assetTreeNode.AnyAssetTreeNode, depth: number) => { + const { item } = node + + if (backendModule.assetIsDirectory(item)) { + const childrenAssetsQuery = directories.directories.find( + (directory) => directory.data?.parentId === item.id, + ) + + const nestedChildren = childrenAssetsQuery?.data?.children.map((child) => + AssetTreeNode.fromAsset( + child, + item.id, + item.id, + depth, + `${node.path}/${child.title}`, + null, + child.id, + ), + ) + + if (childrenAssetsQuery == null || childrenAssetsQuery.isLoading) { + node = node.with({ + children: [ + AssetTreeNode.fromAsset( + backendModule.createSpecialLoadingAsset(item.id), + item.id, + item.id, + depth, + '', + ), + ], + }) + } else if (nestedChildren?.length === 0) { + node = node.with({ + children: [ + AssetTreeNode.fromAsset( + backendModule.createSpecialEmptyAsset(item.id), + item.id, + item.id, + depth, + '', + ), + ], + }) + } else if (nestedChildren != null) { + node = node.with({ + children: nestedChildren.map((child) => appendChildrenRecursively(child, depth + 1)), + }) + } + } + + return node + } + + const node = AssetTreeNode.fromAsset( + content, + rootId, + rootId, + 0, + `${backend.rootPath}/${content.title}`, + null, + content.id, + ) + + return appendChildrenRecursively(node, 1) + }) + + return new AssetTreeNode( + rootDirectory, rootParentDirectoryId, rootParentDirectoryId, + children, -1, backend.rootPath, null, + rootId, ) - }) - const [isDraggingFiles, setIsDraggingFiles] = React.useState(false) - const [droppedFilesCount, setDroppedFilesCount] = React.useState(0) - const isCloud = backend.type === backendModule.BackendType.remote - /** Events sent when the asset list was still loading. */ - const queuedAssetListEventsRef = React.useRef([]) - const rootRef = React.useRef(null) - const cleanupRootRef = React.useRef(() => {}) - const mainDropzoneRef = React.useRef(null) - const lastSelectedIdsRef = React.useRef< - backendModule.AssetId | ReadonlySet | null - >(null) - const headerRowRef = React.useRef(null) - const assetTreeRef = React.useRef(assetTree) - const pasteDataRef = React.useRef - > | null>(null) - const nodeMapRef = React.useRef< - ReadonlyMap - >(new Map()) + }, [ + directories, + rootDirectoryContent, + rootDirectory, + rootParentDirectoryId, + backend.rootPath, + rootDirectoryId, + ]) + const filter = React.useMemo(() => { const globCache: Record = {} if (/^\s*$/.test(query.query)) { @@ -557,9 +674,37 @@ export default function AssetsTable(props: AssetsTableProps) { } } }, [query]) + + const visibilities = React.useMemo(() => { + const map = new Map() + const processNode = (node: assetTreeNode.AnyAssetTreeNode) => { + let displayState = Visibility.hidden + const visible = filter?.(node) ?? true + for (const child of node.children ?? []) { + if (visible && child.item.type === backendModule.AssetType.specialEmpty) { + map.set(child.key, Visibility.visible) + } else { + processNode(child) + } + if (map.get(child.key) !== Visibility.hidden) { + displayState = Visibility.faded + } + } + if (visible) { + displayState = Visibility.visible + } + map.set(node.key, displayState) + return displayState + } + processNode(assetTree) + return map + }, [assetTree, filter]) + const displayItems = React.useMemo(() => { if (sortInfo == null) { - return assetTree.preorderTraversal() + return assetTree.preorderTraversal((children) => + children.filter((child) => expandedDirectoryIdsSet.has(child.directoryId)), + ) } else { const multiplier = sortInfo.direction === sorting.SortDirection.ascending ? 1 : -1 let compare: (a: assetTreeNode.AnyAssetTreeNode, b: assetTreeNode.AnyAssetTreeNode) => number @@ -593,38 +738,39 @@ export default function AssetsTable(props: AssetsTableProps) { break } } - return assetTree.preorderTraversal((tree) => [...tree].sort(compare)) - } - }, [assetTree, sortInfo]) - const visibilities = React.useMemo(() => { - const map = new Map() - const processNode = (node: assetTreeNode.AnyAssetTreeNode) => { - let displayState = Visibility.hidden - const visible = filter?.(node) ?? true - for (const child of node.children ?? []) { - if (visible && child.item.type === backendModule.AssetType.specialEmpty) { - map.set(child.key, Visibility.visible) - } else { - processNode(child) - } - if (map.get(child.key) !== Visibility.hidden) { - displayState = Visibility.faded - } - } - if (visible) { - displayState = Visibility.visible - } - map.set(node.key, displayState) - return displayState + return assetTree.preorderTraversal((tree) => + [...tree].filter((child) => expandedDirectoryIdsSet.has(child.directoryId)).sort(compare), + ) } - processNode(assetTree) - return map - }, [assetTree, filter]) + }, [assetTree, sortInfo, expandedDirectoryIdsSet]) + const visibleItems = React.useMemo( () => displayItems.filter((item) => visibilities.get(item.key) !== Visibility.hidden), [displayItems, visibilities], ) + const [isDraggingFiles, setIsDraggingFiles] = React.useState(false) + const [droppedFilesCount, setDroppedFilesCount] = React.useState(0) + const isCloud = backend.type === backendModule.BackendType.remote + /** Events sent when the asset list was still loading. */ + const queuedAssetListEventsRef = React.useRef([]) + const rootRef = React.useRef(null) + const cleanupRootRef = React.useRef(() => {}) + const mainDropzoneRef = React.useRef(null) + const lastSelectedIdsRef = React.useRef< + backendModule.AssetId | ReadonlySet | null + >(null) + const headerRowRef = React.useRef(null) + const assetTreeRef = React.useRef(assetTree) + const pasteDataRef = React.useRef + > | null>(null) + const nodeMapRef = React.useRef< + ReadonlyMap + >(new Map()) + + const queryClient = useQueryClient() + const isMainDropzoneVisible = intersectionHooks.useIntersectionRatio( rootRef, mainDropzoneRef, @@ -633,7 +779,6 @@ export default function AssetsTable(props: AssetsTableProps) { true, ) - const updateSecret = useMutation(backendMutationOptions(backend, 'updateSecret')).mutateAsync React.useEffect(() => { previousCategoryRef.current = category }) @@ -915,95 +1060,6 @@ export default function AssetsTable(props: AssetsTableProps) { [driveStore, isCloud, setCanDownload], ) - const overwriteNodes = React.useCallback( - (newAssets: backendModule.AnyAsset[]) => { - setInitialized(true) - mostRecentlySelectedIndexRef.current = null - selectionStartIndexRef.current = null - // This is required, otherwise we are using an outdated - // `nameOfProjectToImmediatelyOpen`. - const nameOfProjectToImmediatelyOpen = nameOfProjectToImmediatelyOpenRef.current - const rootParentDirectoryId = backendModule.DirectoryId('') - const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId) - const rootId = rootDirectory.id - const children = newAssets.map((asset) => - AssetTreeNode.fromAsset( - asset, - rootId, - rootId, - 0, - `${backend.rootPath}/${asset.title}`, - null, - ), - ) - const newRootNode = new AssetTreeNode( - rootDirectory, - rootParentDirectoryId, - rootParentDirectoryId, - children, - -1, - backend.rootPath, - null, - rootId, - true, - ) - setAssetTree(newRootNode) - // The project name here might also be a string with project id, e.g. - // when opening a project file from explorer on Windows. - const isInitialProject = (asset: backendModule.AnyAsset) => - asset.title === nameOfProjectToImmediatelyOpen || - asset.id === nameOfProjectToImmediatelyOpen - if (nameOfProjectToImmediatelyOpen != null) { - const projectToLoad = newAssets.filter(backendModule.assetIsProject).find(isInitialProject) - if (projectToLoad != null) { - const backendType = backendModule.BackendType.local - const { id, title, parentId } = projectToLoad - doOpenProject({ type: backendType, id, title, parentId }) - } else { - toastAndLog('findProjectError', null, nameOfProjectToImmediatelyOpen) - } - } - setQueuedAssetEvents((oldQueuedAssetEvents) => { - if (oldQueuedAssetEvents.length !== 0) { - queueMicrotask(() => { - for (const event of oldQueuedAssetEvents) { - dispatchAssetEvent(event) - } - }) - } - return [] - }) - nameOfProjectToImmediatelyOpenRef.current = null - }, - [doOpenProject, rootDirectoryId, backend.rootPath, dispatchAssetEvent, toastAndLog], - ) - const overwriteNodesRef = React.useRef(overwriteNodes) - overwriteNodesRef.current = overwriteNodes - - React.useEffect(() => { - if (initializedRef.current) { - overwriteNodesRef.current([]) - } - }, [backend, category]) - - const rootDirectoryQuery = useBackendQuery( - backend, - 'listDirectory', - [ - { - parentId: null, - filterBy: CATEGORY_TO_FILTER_BY[category], - recentProjects: category === Category.recent, - labels: null, - }, - // The root directory has no name. This is also SAFE, as there is a different error - // message when the directory is the root directory (when `parentId == null`). - '(root)', - ], - { queryKey: [], staleTime: 0, meta: { persist: false } }, - ) - const isLoading = rootDirectoryQuery.isLoading - React.useEffect(() => { if (isLoading) { nameOfProjectToImmediatelyOpenRef.current = initialProjectName @@ -1032,12 +1088,6 @@ export default function AssetsTable(props: AssetsTableProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialProjectName]) - React.useEffect(() => { - if (rootDirectoryQuery.data) { - overwriteNodes(rootDirectoryQuery.data) - } - }, [rootDirectoryQuery.data, overwriteNodes]) - React.useEffect(() => { const savedEnabledColumns = localStorage.get('enabledColumns') if (savedEnabledColumns != null) { @@ -1046,10 +1096,8 @@ export default function AssetsTable(props: AssetsTableProps) { }, [localStorage]) React.useEffect(() => { - if (initialized) { - localStorage.set('enabledColumns', [...enabledColumns]) - } - }, [enabledColumns, initialized, localStorage]) + localStorage.set('enabledColumns', [...enabledColumns]) + }, [enabledColumns, localStorage]) React.useEffect( () => @@ -1062,128 +1110,30 @@ export default function AssetsTable(props: AssetsTableProps) { [driveStore, setAssetPanelProps, setIsAssetPanelTemporarilyVisible], ) - const directoryListAbortControllersRef = React.useRef( - new Map(), - ) - const doToggleDirectoryExpansion = React.useCallback( + const doToggleDirectoryExpansion = useEventCallback( ( directoryId: backendModule.DirectoryId, - key: backendModule.DirectoryId, - title?: string | null, + _key: backendModule.DirectoryId, override?: boolean, ) => { - const directory = nodeMapRef.current.get(key) - const isExpanded = directory?.children != null && directory.isExpanded + const isExpanded = expandedDirectoryIdsSet.has(directoryId) const shouldExpand = override ?? !isExpanded - if (shouldExpand === isExpanded) { - // This is fine, as this is near the top of a very long function. - // eslint-disable-next-line no-restricted-syntax - return - } - if (!shouldExpand) { - const abortController = directoryListAbortControllersRef.current.get(directoryId) - if (abortController != null) { - abortController.abort() - directoryListAbortControllersRef.current.delete(directoryId) - } - setAssetTree((oldAssetTree) => - oldAssetTree.map((item) => (item.key !== key ? item : item.with({ isExpanded: false }))), - ) - } else { - setAssetTree((oldAssetTree) => - oldAssetTree.map((item) => - item.key !== key ? item - : item.children != null ? item.with({ isExpanded: true }) - : item.with({ - isExpanded: true, - children: [ - AssetTreeNode.fromAsset( - backendModule.createSpecialLoadingAsset(directoryId), - key, - directoryId, - item.depth + 1, - '', - null, - ), - ], - }), - ), - ) - void (async () => { - const abortController = new AbortController() - directoryListAbortControllersRef.current.set(directoryId, abortController) - const displayedTitle = title ?? nodeMapRef.current.get(key)?.item.title ?? '(unknown)' - const childAssets = await backend - .listDirectory( - { - parentId: directoryId, - filterBy: CATEGORY_TO_FILTER_BY[category], - recentProjects: category === Category.recent, - labels: null, - }, - displayedTitle, - ) - .catch((error) => { - toastAndLog('listFolderBackendError', error, displayedTitle) - throw error - }) - if (!abortController.signal.aborted) { - setAssetTree((oldAssetTree) => - oldAssetTree.map((item) => { - if (item.key !== key) { - return item - } else { - const initialChildren = item.children?.filter( - (child) => child.item.type !== backendModule.AssetType.specialLoading, - ) - const childAssetsMap = new Map(childAssets.map((asset) => [asset.id, asset])) - for (const child of initialChildren ?? []) { - const newChild = childAssetsMap.get(child.item.id) - if (newChild != null) { - child.item = newChild - childAssetsMap.delete(child.item.id) - } - } - const childAssetNodes = Array.from(childAssetsMap.values(), (child) => - AssetTreeNode.fromAsset( - child, - key, - directoryId, - item.depth + 1, - `${item.path}/${child.title}`, - null, - ), - ) - const specialEmptyAsset: backendModule.SpecialEmptyAsset | null = - ( - (initialChildren != null && initialChildren.length !== 0) || - childAssetNodes.length !== 0 - ) ? - null - : backendModule.createSpecialEmptyAsset(directoryId) - const children = - specialEmptyAsset != null ? - [ - AssetTreeNode.fromAsset( - specialEmptyAsset, - key, - directoryId, - item.depth + 1, - '', - null, - ), - ] - : initialChildren == null || initialChildren.length === 0 ? childAssetNodes - : [...initialChildren, ...childAssetNodes].sort(AssetTreeNode.compare) - return item.with({ children }) - } - }), + + if (shouldExpand !== isExpanded) { + React.startTransition(() => { + if (shouldExpand) { + setExpandedDirectoryIds((currentExpandedDirectoryIds) => [ + ...currentExpandedDirectoryIds, + directoryId, + ]) + } else { + setExpandedDirectoryIds((currentExpandedDirectoryIds) => + currentExpandedDirectoryIds.filter((id) => id !== directoryId), ) } - })() + }) } }, - [category, backend, toastAndLog], ) const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial) @@ -1194,8 +1144,10 @@ export default function AssetsTable(props: AssetsTableProps) { const setMostRecentlySelectedIndex = React.useCallback( (index: number | null, isKeyboard = false) => { - mostRecentlySelectedIndexRef.current = index - setKeyboardSelectedIndex(isKeyboard ? index : null) + React.startTransition(() => { + mostRecentlySelectedIndexRef.current = index + setKeyboardSelectedIndex(isKeyboard ? index : null) + }) }, [], ) @@ -1261,7 +1213,7 @@ export default function AssetsTable(props: AssetsTableProps) { name={item.item.title} doCreate={async (_name, value) => { try { - await updateSecret([id, { value }, item.item.title]) + await updateSecretMutation.mutateAsync([id, { value }, item.item.title]) } catch (error) { toastAndLog(null, error) } @@ -1283,7 +1235,7 @@ export default function AssetsTable(props: AssetsTableProps) { // The folder is expanded; collapse it. event.preventDefault() event.stopPropagation() - doToggleDirectoryExpansion(item.item.id, item.key, null, false) + doToggleDirectoryExpansion(item.item.id, item.key, false) } else if (prevIndex != null) { // Focus parent if there is one. let index = prevIndex - 1 @@ -1308,7 +1260,7 @@ export default function AssetsTable(props: AssetsTableProps) { // The folder is collapsed; expand it. event.preventDefault() event.stopPropagation() - doToggleDirectoryExpansion(item.item.id, item.key, null, true) + doToggleDirectoryExpansion(item.item.id, item.key, true) } break } @@ -1402,7 +1354,7 @@ export default function AssetsTable(props: AssetsTableProps) { } }, [setMostRecentlySelectedIndex]) - const getNewProjectName = React.useCallback( + const getNewProjectName = useEventCallback( (templateName: string | null, parentKey: backendModule.DirectoryId | null) => { const prefix = `${templateName ?? 'New Project'} ` const projectNameTemplate = new RegExp(`^${prefix}(?\\d+)$`) @@ -1417,68 +1369,43 @@ export default function AssetsTable(props: AssetsTableProps) { .map((maybeIndex) => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0)) return `${prefix}${Math.max(0, ...projectIndices) + 1}` }, - [assetTree, nodeMapRef], ) - const deleteAsset = React.useCallback((key: backendModule.AssetId) => { - setAssetTree((oldAssetTree) => oldAssetTree.filter((item) => item.key !== key)) - }, []) + const deleteAsset = useEventCallback((assetId: backendModule.AssetId) => { + const asset = nodeMapRef.current.get(assetId)?.item + + if (asset) { + const listDirectoryQuery = queryClient.getQueryCache().find({ + queryKey: ['listDirectory', backend.type, asset.parentId], + exact: false, + }) + + if (listDirectoryQuery?.state.data) { + listDirectoryQuery.setData({ + ...listDirectoryQuery.state.data, + children: listDirectoryQuery.state.data.children.filter((child) => child.id !== assetId), + }) + } + } + }) /** All items must have the same type. */ - const insertAssets = React.useCallback( - ( - assets: readonly backendModule.AnyAsset[], - parentKey: backendModule.DirectoryId | null, - parentId: backendModule.DirectoryId | null, - getInitialAssetEvents: (id: backendModule.AssetId) => readonly assetEvent.AssetEvent[] | null, - ) => { - const actualParentKey = parentKey ?? rootDirectoryId + const insertAssets = useEventCallback( + (assets: readonly backendModule.AnyAsset[], parentId: backendModule.DirectoryId | null) => { const actualParentId = parentId ?? rootDirectoryId - setAssetTree((oldAssetTree) => - oldAssetTree.map((item) => - item.key !== actualParentKey ? - item - : insertAssetTreeNodeChildren( - item, - assets, - actualParentKey, - actualParentId, - getInitialAssetEvents, - ), - ), - ) - }, - [rootDirectoryId], - ) - const insertArbitraryAssets = React.useCallback( - ( - assets: backendModule.AnyAsset[], - parentKey: backendModule.DirectoryId | null, - parentId: backendModule.DirectoryId | null, - getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null, - getInitialAssetEvents: ( - id: backendModule.AssetId, - ) => readonly assetEvent.AssetEvent[] | null = () => null, - ) => { - const actualParentKey = parentKey ?? rootDirectoryId - const actualParentId = parentId ?? rootDirectoryId - setAssetTree((oldAssetTree) => { - return oldAssetTree.map((item) => - item.key !== actualParentKey ? - item - : insertArbitraryAssetTreeNodeChildren( - item, - assets, - actualParentKey, - actualParentId, - getKey, - getInitialAssetEvents, - ), - ) + const listDirectoryQuery = queryClient.getQueryCache().find({ + queryKey: ['listDirectory', backend.type, actualParentId], + exact: false, }) + + if (listDirectoryQuery?.state.data) { + listDirectoryQuery.setData({ + ...listDirectoryQuery.state.data, + children: [...listDirectoryQuery.state.data.children, ...assets], + }) + } }, - [rootDirectoryId], ) // This is not a React component, even though it contains JSX. @@ -1505,10 +1432,14 @@ export default function AssetsTable(props: AssetsTableProps) { labels: [], description: null, } - doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true) - insertAssets([placeholderItem], event.parentKey, event.parentId, () => [ - { type: AssetEventType.newFolder, placeholderId: placeholderItem.id }, + + doToggleDirectoryExpansion(event.parentId, event.parentKey, true) + insertAssets([placeholderItem], event.parentId) + + createDirectoryMutation.mutate([ + { parentId: placeholderItem.parentId, title: placeholderItem.title }, ]) + break } case AssetListEventType.newProject: { @@ -1516,6 +1447,7 @@ export default function AssetsTable(props: AssetsTableProps) { const dummyId = backendModule.ProjectId(uniqueString.uniqueString()) const path = backend instanceof LocalBackend ? backend.joinPath(event.parentId, projectName) : null + const placeholderItem: backendModule.ProjectAsset = { type: backendModule.AssetType.project, id: dummyId, @@ -1532,19 +1464,37 @@ export default function AssetsTable(props: AssetsTableProps) { labels: [], description: null, } - doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true) - insertAssets([placeholderItem], event.parentKey, event.parentId, () => [ - { - type: AssetEventType.newProject, - placeholderId: dummyId, - templateId: event.templateId, - datalinkId: event.datalinkId, - originalId: null, - versionId: null, - ...(event.onCreated ? { onCreated: event.onCreated } : {}), - ...(event.onError ? { onError: event.onError } : {}), - }, - ]) + doToggleDirectoryExpansion(event.parentId, event.parentKey, true) + + insertAssets([placeholderItem], event.parentId) + + void createProjectMutation + .mutateAsync([ + { + parentDirectoryId: placeholderItem.parentId, + projectName: placeholderItem.title, + ...(event.templateId == null ? {} : { projectTemplateName: event.templateId }), + ...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }), + }, + ]) + .catch((error) => { + event.onError?.() + + deleteAsset(placeholderItem.id) + toastAndLog('createProjectError', error) + + throw error + }) + .then((createdProject) => { + event.onCreated?.(createdProject) + doOpenProject({ + id: createdProject.projectId, + type: backend.type, + parentId: placeholderItem.parentId, + title: placeholderItem.title, + }) + }) + break } case AssetListEventType.uploadFiles: { @@ -1564,14 +1514,88 @@ export default function AssetsTable(props: AssetsTableProps) { ) const ownerPermission = permissions.tryGetSingletonOwnerPermission(user) const fileMap = new Map() - const getInitialAssetEvents = ( - id: backendModule.AssetId, - ): readonly assetEvent.AssetEvent[] | null => { - const file = fileMap.get(id) - return file == null ? null : ( - [{ type: AssetEventType.uploadFiles, files: new Map([[id, file]]) }] - ) + + const doUploadFile = async (asset: backendModule.AnyAsset, method: 'new' | 'update') => { + const file = fileMap.get(asset.id) + + if (file != null) { + const fileId = method === 'new' ? null : asset.id + + switch (true) { + case backendModule.assetIsProject(asset): { + const { extension } = backendModule.extractProjectExtension(file.name) + const title = backendModule.stripProjectExtension(asset.title) + + const assetNode = nodeMapRef.current.get(asset.id) + + if (assetNode == null) { + // eslint-disable-next-line no-restricted-syntax + return + } + + if (backend.type === backendModule.BackendType.local && localBackend != null) { + const directory = localBackendModule.extractTypeAndId(assetNode.directoryId).id + let id: string + if ( + 'backendApi' in window && + // This non-standard property is defined in Electron. + 'path' in file + ) { + 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 = localBackendModule.newProjectId(projectManager.UUID(id)) + + await getProjectDetailsMutation + .mutateAsync([projectId, asset.parentId, file.name]) + .catch((error) => { + deleteAsset(projectId) + toastAndLog('uploadProjectError', error) + }) + } else { + uploadFileMutation + .mutateAsync([ + { + fileId, + fileName: `${title}.${extension}`, + parentDirectoryId: asset.parentId, + }, + file, + ]) + .catch((error) => { + deleteAsset(asset.id) + toastAndLog('uploadProjectError', error) + }) + } + + break + } + case backendModule.assetIsFile(asset): { + uploadFileMutation.mutate([ + { fileId, fileName: asset.title, parentDirectoryId: asset.parentId }, + file, + ]) + + break + } + default: + break + } + } } + if (duplicateFiles.length === 0 && duplicateProjects.length === 0) { const placeholderFiles = files.map((file) => { const asset = backendModule.createPlaceholderFileAsset( @@ -1582,6 +1606,7 @@ export default function AssetsTable(props: AssetsTableProps) { fileMap.set(asset.id, file) return asset }) + const placeholderProjects = projects.map((project) => { const basename = backendModule.stripProjectExtension(project.name) const asset = backendModule.createPlaceholderProjectAsset( @@ -1594,9 +1619,14 @@ export default function AssetsTable(props: AssetsTableProps) { fileMap.set(asset.id, project) return asset }) - doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true) - insertAssets(placeholderFiles, event.parentKey, event.parentId, getInitialAssetEvents) - insertAssets(placeholderProjects, event.parentKey, event.parentId, getInitialAssetEvents) + + const assets = [...placeholderFiles, ...placeholderProjects] + + doToggleDirectoryExpansion(event.parentId, event.parentKey, true) + + insertAssets(assets, event.parentId) + + void Promise.all(assets.map((asset) => doUploadFile(asset, 'new'))) } else { const siblingFilesByName = new Map(siblingFiles.map((file) => [file.title, file])) const siblingProjectsByName = new Map( @@ -1641,8 +1671,15 @@ export default function AssetsTable(props: AssetsTableProps) { siblingProjectNames={siblingProjectsByName.keys()} nonConflictingFileCount={files.length - conflictingFiles.length} nonConflictingProjectCount={projects.length - conflictingProjects.length} + doUpdateConflicting={(resolvedConflicts) => { + for (const conflict of resolvedConflicts) { + fileMap.set(conflict.current.id, conflict.file) + void doUploadFile(conflict.current, 'update') + } + }} doUploadNonConflicting={() => { - doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true) + doToggleDirectoryExpansion(event.parentId, event.parentKey, true) + const newFiles = files .filter((file) => !siblingFileTitles.has(file.name)) .map((file) => { @@ -1654,6 +1691,7 @@ export default function AssetsTable(props: AssetsTableProps) { fileMap.set(asset.id, file) return asset }) + const newProjects = projects .filter( (project) => @@ -1671,8 +1709,14 @@ export default function AssetsTable(props: AssetsTableProps) { fileMap.set(asset.id, project) return asset }) - insertAssets(newFiles, event.parentKey, event.parentId, getInitialAssetEvents) - insertAssets(newProjects, event.parentKey, event.parentId, getInitialAssetEvents) + + const assets = [...newFiles, ...newProjects] + + insertAssets(assets, event.parentId) + + for (const asset of assets) { + void doUploadFile(asset, 'new') + } }} />, ) @@ -1691,14 +1735,18 @@ export default function AssetsTable(props: AssetsTableProps) { labels: [], description: null, } - doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true) - insertAssets([placeholderItem], event.parentKey, event.parentId, () => [ + doToggleDirectoryExpansion(event.parentId, event.parentKey, true) + insertAssets([placeholderItem], event.parentId) + + createDatalinkMutation.mutate([ { - type: AssetEventType.newDatalink, - placeholderId: placeholderItem.id, + parentDirectoryId: placeholderItem.parentId, + datalinkId: null, + name: placeholderItem.title, value: event.value, }, ]) + break } case AssetListEventType.newSecret: { @@ -1713,14 +1761,22 @@ export default function AssetsTable(props: AssetsTableProps) { labels: [], description: null, } - doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true) - insertAssets([placeholderItem], event.parentKey, event.parentId, () => [ - { type: AssetEventType.newSecret, placeholderId: placeholderItem.id, value: event.value }, + + doToggleDirectoryExpansion(event.parentId, event.parentKey, true) + insertAssets([placeholderItem], event.parentId) + + createSecretMutation.mutate([ + { + parentDirectoryId: placeholderItem.parentId, + name: placeholderItem.title, + value: event.value, + }, ]) + break } case AssetListEventType.insertAssets: { - insertArbitraryAssets(event.assets, event.parentKey, event.parentId) + insertAssets(event.assets, event.parentId) break } case AssetListEventType.duplicateProject: { @@ -1732,6 +1788,7 @@ export default function AssetsTable(props: AssetsTableProps) { index += 1 title = `${event.original.title} (${index})` } + const placeholderItem: backendModule.ProjectAsset = { type: backendModule.AssetType.project, id: backendModule.ProjectId(uniqueString.uniqueString()), @@ -1747,16 +1804,26 @@ export default function AssetsTable(props: AssetsTableProps) { labels: [], description: null, } - insertAssets([placeholderItem], event.parentKey, event.parentId, () => [ - { - type: AssetEventType.newProject, - placeholderId: placeholderItem.id, - templateId: null, - datalinkId: null, - originalId: event.original.id, - versionId: event.versionId, - }, - ]) + + insertAssets([placeholderItem], event.parentId) + + void duplicateProjectMutation + .mutateAsync([event.original.id, event.versionId, placeholderItem.title]) + .catch((error) => { + deleteAsset(placeholderItem.id) + toastAndLog('createProjectError', error) + + throw error + }) + .then((project) => { + doOpenProject({ + type: backend.type, + parentId: event.parentId, + title: placeholderItem.title, + id: project.projectId, + }) + }) + break } case AssetListEventType.willDelete: { @@ -1769,32 +1836,12 @@ export default function AssetsTable(props: AssetsTableProps) { break } case AssetListEventType.copy: { - const ids = new Set() - const getKey = (asset: backendModule.AnyAsset) => { - const newId = backendModule.createPlaceholderAssetId(asset.type) - ids.add(newId) - return newId - } - const assetEvents: readonly assetEvent.AssetEvent[] = [ - { - type: AssetEventType.copy, - ids, - newParentKey: event.newParentKey, - newParentId: event.newParentId, - }, - ] - insertArbitraryAssets( - event.items, - event.newParentKey, - event.newParentId, - getKey, - () => assetEvents, - ) + insertAssets(event.items, event.newParentId) break } case AssetListEventType.move: { deleteAsset(event.key) - insertAssets([event.item], event.newParentKey, event.newParentId, () => null) + insertAssets([event.item], event.newParentId) break } case AssetListEventType.delete: { @@ -1818,7 +1865,7 @@ export default function AssetsTable(props: AssetsTableProps) { break } case AssetListEventType.closeFolder: { - doToggleDirectoryExpansion(event.id, event.key, null, false) + doToggleDirectoryExpansion(event.id, event.key, false) break } } @@ -1857,7 +1904,7 @@ export default function AssetsTable(props: AssetsTableProps) { if (pasteData.data.has(newParentKey)) { toast.toast.error('Cannot paste a folder into itself.') } else { - doToggleDirectoryExpansion(newParentId, newParentKey, null, true) + doToggleDirectoryExpansion(newParentId, newParentKey, true) if (pasteData.type === PasteType.copy) { const assets = Array.from(pasteData.data, (id) => nodeMapRef.current.get(id)).flatMap( (asset) => (asset ? [asset.item] : []), @@ -1939,6 +1986,7 @@ export default function AssetsTable(props: AssetsTableProps) { // The type MUST be here to trigger excess property errors at typecheck time. () => ({ backend, + expandedDirectoryIds, rootDirectoryId, visibilities, scrollContainerRef: rootRef, @@ -1960,6 +2008,7 @@ export default function AssetsTable(props: AssetsTableProps) { }), [ backend, + expandedDirectoryIds, rootDirectoryId, visibilities, category, @@ -2186,19 +2235,26 @@ export default function AssetsTable(props: AssetsTableProps) { [visibleItems, calculateNewKeys, setSelectedKeys, setMostRecentlySelectedIndex], ) - const getAsset = React.useCallback( + const getAsset = useEventCallback( (key: backendModule.AssetId) => nodeMapRef.current.get(key)?.item ?? null, - [nodeMapRef], ) - const setAsset = React.useCallback( - (key: backendModule.AssetId, asset: backendModule.AnyAsset) => { - setAssetTree((oldAssetTree) => - oldAssetTree.map((item) => (item.key === key ? item.with({ item: asset }) : item)), - ) - updateAssetRef.current[asset.id]?.(asset) + const setAsset = useEventCallback( + (assetId: backendModule.AssetId, asset: backendModule.AnyAsset) => { + const listDirectoryQuery = queryClient.getQueryCache().find({ + queryKey: ['listDirectory', backend.type, asset.parentId], + exact: false, + }) + + if (listDirectoryQuery?.state.data) { + listDirectoryQuery.setData({ + ...listDirectoryQuery.state.data, + children: listDirectoryQuery.state.data.children.map((child) => + child.id === assetId ? asset : child, + ), + }) + } }, - [], ) React.useImperativeHandle(assetManagementApiRef, () => ({ @@ -2235,7 +2291,7 @@ export default function AssetsTable(props: AssetsTableProps) { : displayItems.map((item, i) => { return ( { if (instance != null) { updateAssetRef.current[item.item.id] = instance diff --git a/app/dashboard/src/layouts/DriveBar.tsx b/app/dashboard/src/layouts/DriveBar.tsx index defd3e1ffa5e..1c193e97b595 100644 --- a/app/dashboard/src/layouts/DriveBar.tsx +++ b/app/dashboard/src/layouts/DriveBar.tsx @@ -238,11 +238,13 @@ export default function DriveBar(props: DriveBarProps) { loaderPosition="icon" onPress={() => { setIsCreatingProject(true) + doCreateProject( null, null, (project) => { setCreatedProjectId(project.projectId) + setIsCreatingProject(false) }, () => { setIsCreatingProject(false) diff --git a/app/dashboard/src/layouts/StartModal.tsx b/app/dashboard/src/layouts/StartModal.tsx index 87b507a45ae8..b6ae5100bb7b 100644 --- a/app/dashboard/src/layouts/StartModal.tsx +++ b/app/dashboard/src/layouts/StartModal.tsx @@ -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) { { - createProjectRaw(templateId, templateName) + createProject(templateId, templateName) opts.close() }} /> diff --git a/app/dashboard/src/layouts/VersionChecker.tsx b/app/dashboard/src/layouts/VersionChecker.tsx index 69a5534cd14d..40cacb1795d4 100644 --- a/app/dashboard/src/layouts/VersionChecker.tsx +++ b/app/dashboard/src/layouts/VersionChecker.tsx @@ -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' diff --git a/app/dashboard/src/modals/DuplicateAssetsModal.tsx b/app/dashboard/src/modals/DuplicateAssetsModal.tsx index d34453d746ac..5effe20d2a65 100644 --- a/app/dashboard/src/modals/DuplicateAssetsModal.tsx +++ b/app/dashboard/src/modals/DuplicateAssetsModal.tsx @@ -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) { { - 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')} diff --git a/app/dashboard/src/pages/dashboard/Dashboard.tsx b/app/dashboard/src/pages/dashboard/Dashboard.tsx index 1fb0ca4da177..e5f77160d7a5 100644 --- a/app/dashboard/src/pages/dashboard/Dashboard.tsx +++ b/app/dashboard/src/pages/dashboard/Dashboard.tsx @@ -332,6 +332,7 @@ function DashboardInner(props: DashboardProps) { {appRunner != null && launchedProjects.map((project) => ( void -} - -// ======================= -// === ProjectsContext === -// ======================= - -/** State contained in a `ProjectsContext`. */ -export interface ProjectsContextType extends zustand.StoreApi {} - -const EnsoDevtoolsContext = React.createContext(null) - -/** Props for a {@link EnsoDevtoolsProvider}. */ -export interface ProjectsProviderProps extends Readonly {} - -// ======================== -// === 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((set) => ({ - showVersionChecker: false, - setEnableVersionChecker: (showVersionChecker) => { - set({ showVersionChecker }) - }, - })) - }) - - return {children} -} - -// ============================ -// === 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) -} diff --git a/app/dashboard/src/providers/FeatureFlagsProvider.tsx b/app/dashboard/src/providers/FeatureFlagsProvider.tsx new file mode 100644 index 000000000000..aad5fa9f469a --- /dev/null +++ b/app/dashboard/src/providers/FeatureFlagsProvider.tsx @@ -0,0 +1,118 @@ +/** + * @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 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 + } +} + +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'], + Value extends FeatureFlags['featureFlags'][Key], + >( + key: Key, + value: Value, + ) => void +} + +const flagsStore = createStore((set) => ({ + featureFlags: { + enableMultitabs: false, + enableAssetsTableBackgroundRefresh: false, + // 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: 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 Object.entries(storedFeatureFlags)) { + // This is safe, because we've already validated the feature flags using the schema. + // eslint-disable-next-line no-restricted-syntax + setFeatureFlags(key as keyof typeof storedFeatureFlags, value) + } + } + }) + + useEffect( + () => + flagsStore.subscribe((state, prevState) => { + if (state.featureFlags !== prevState.featureFlags) { + localStorage.set('featureFlags', state.featureFlags) + } + }), + [localStorage], + ) + + return <>{children} +} diff --git a/app/dashboard/src/utilities/AssetTreeNode.ts b/app/dashboard/src/utilities/AssetTreeNode.ts index 0dcc5a07e56b..96940ddb8322 100644 --- a/app/dashboard/src/utilities/AssetTreeNode.ts +++ b/app/dashboard/src/utilities/AssetTreeNode.ts @@ -17,7 +17,6 @@ export interface AssetTreeNodeData | 'directoryId' | 'directoryKey' | 'initialAssetEvents' - | 'isExpanded' | 'item' | 'key' | 'path' @@ -52,7 +51,6 @@ export default class AssetTreeNode 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)], ) diff --git a/app/ide-desktop/common/src/text/english.json b/app/ide-desktop/common/src/text/english.json index 77c23f4bc5bc..b9acf60de776 100644 --- a/app/ide-desktop/common/src/text/english.json +++ b/app/ide-desktop/common/src/text/english.json @@ -423,6 +423,14 @@ "enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "changeLocalRootDirectoryInSettings": "Change your root folder in Settings.", + "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", @@ -815,10 +823,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 share assets only with 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", diff --git a/app/ide-desktop/common/tsconfig.json b/app/ide-desktop/common/tsconfig.json index ffb4957828a0..4db43171dfe4 100644 --- a/app/ide-desktop/common/tsconfig.json +++ b/app/ide-desktop/common/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["DOM", "es2023"], "allowJs": false, "checkJs": false, "skipLibCheck": false From aabd0a328aaa1183d5bdb0c0d46891e6df55aa25 Mon Sep 17 00:00:00 2001 From: Sergey Garin Date: Wed, 14 Aug 2024 13:13:53 +0300 Subject: [PATCH 02/17] Fix lint --- .../src/components/AriaComponents/Form/Form.tsx | 12 ------------ .../src/components/dashboard/ProjectNameColumn.tsx | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/app/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/dashboard/src/components/AriaComponents/Form/Form.tsx index c7aab2590018..425e4da39817 100644 --- a/app/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -13,23 +13,11 @@ import * as aria from '#/components/aria' import * as errorUtils from '#/utilities/error' -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. */ diff --git a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx index 347715f8569d..59f7d97bc766 100644 --- a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -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' From 5da59bdf2a6db8b6f24197c2bfb6bf2e33143367 Mon Sep 17 00:00:00 2001 From: Sergey Garin Date: Mon, 19 Aug 2024 15:34:45 +0300 Subject: [PATCH 03/17] Address issues --- app/dashboard/src/hooks/projectHooks.ts | 2 +- app/dashboard/src/hooks/syncRefHooks.ts | 7 +++++-- app/dashboard/src/layouts/AssetsTable.tsx | 5 ++--- .../src/providers/FeatureFlagsProvider.tsx | 14 +++++--------- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/app/dashboard/src/hooks/projectHooks.ts b/app/dashboard/src/hooks/projectHooks.ts index b67cc3e5a122..5e5761c1e4c6 100644 --- a/app/dashboard/src/hooks/projectHooks.ts +++ b/app/dashboard/src/hooks/projectHooks.ts @@ -236,7 +236,7 @@ export function useOpenProject() { return eventCallbacks.useEventCallback((project: LaunchedProject) => { if (!enableMultitabs) { - // Since multiple tabs cannot be opened at the sametime, the opened projects need to be closed first. + // 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() } diff --git a/app/dashboard/src/hooks/syncRefHooks.ts b/app/dashboard/src/hooks/syncRefHooks.ts index c68cee8891f6..21f3f13204d5 100644 --- a/app/dashboard/src/hooks/syncRefHooks.ts +++ b/app/dashboard/src/hooks/syncRefHooks.ts @@ -12,8 +12,11 @@ import * as React from 'react' export function useSyncRef(value: T): Readonly> { 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. + /* + 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 diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index 51627c1a6e01..8c822f74249b 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -390,8 +390,7 @@ export default function AssetsTable(props: AssetsTableProps) { ) const directories = useQueries({ - // we fetch only expanded directories - // this is an optimization technique to reduce the amount of data to fetch + // We query only expanded directories, as we don't want to load the data for directories that are not visible. queries: React.useMemo( () => expandedDirectoryIds.map((directoryId) => @@ -450,7 +449,7 @@ export default function AssetsTable(props: AssetsTableProps) { }) /** - * ReturnType of the query function for the listDirectory query. + * Return type of the query function for the listDirectory query. */ type ListDirectoryQueryDataType = (typeof directories)['rootDirectory']['data'] diff --git a/app/dashboard/src/providers/FeatureFlagsProvider.tsx b/app/dashboard/src/providers/FeatureFlagsProvider.tsx index aad5fa9f469a..521eb967337b 100644 --- a/app/dashboard/src/providers/FeatureFlagsProvider.tsx +++ b/app/dashboard/src/providers/FeatureFlagsProvider.tsx @@ -7,6 +7,7 @@ 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' @@ -39,12 +40,9 @@ export interface FeatureFlags { readonly enableAssetsTableBackgroundRefresh: boolean readonly assetsTableBackgroundRefreshInterval: number } - readonly setFeatureFlags: < - Key extends keyof FeatureFlags['featureFlags'], - Value extends FeatureFlags['featureFlags'][Key], - >( + readonly setFeatureFlags: ( key: Key, - value: Value, + value: FeatureFlags['featureFlags'][Key], ) => void } @@ -96,10 +94,8 @@ export function FeatureFlagsProvider({ children }: { children: ReactNode }) { const storedFeatureFlags = localStorage.get('featureFlags') if (storedFeatureFlags) { - for (const [key, value] of Object.entries(storedFeatureFlags)) { - // This is safe, because we've already validated the feature flags using the schema. - // eslint-disable-next-line no-restricted-syntax - setFeatureFlags(key as keyof typeof storedFeatureFlags, value) + for (const [key, value] of unsafeEntries(storedFeatureFlags)) { + setFeatureFlags(key, value) } } }) From 27a9ee85cf9ac87dd347b1f5f0b4369b35c3614b Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 27 Aug 2024 11:43:50 +0300 Subject: [PATCH 04/17] Fix after merge --- app/dashboard/src/layouts/AssetsTable.tsx | 90 +++++++++-------------- 1 file changed, 36 insertions(+), 54 deletions(-) diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index 5a931bffaacf..037413d4d679 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -1,7 +1,13 @@ /** @file Table displaying a list of projects. */ import * as React from 'react' -import { queryOptions, useMutation, useQueries, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { + queryOptions, + useMutation, + useQueries, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query' import * as toast from 'react-toastify' import invariant from 'tiny-invariant' import * as z from 'zod' @@ -11,12 +17,12 @@ import DropFilesImage from '#/assets/drop_files.svg' import * as mimeTypes from '#/data/mimeTypes' import * as autoScrollHooks from '#/hooks/autoScrollHooks' -import { backendMutationOptions, +import { + backendMutationOptions, useListTags, useListUserGroups, useListUsers, } from '#/hooks/backendHooks' -import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as intersectionHooks from '#/hooks/intersectionHooks' import * as projectHooks from '#/hooks/projectHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' @@ -341,62 +347,28 @@ export default function AssetsTable(props: AssetsTableProps) { const users = useListUsers(backend) const userGroups = useListUserGroups(backend) + const organizationQuery = useSuspenseQuery({ queryKey: [backend.type, 'getOrganization'], queryFn: () => backend.getOrganization(), }) + const organization = organizationQuery.data - const rootDirectoryId = React.useMemo(() => { - const id = - 'homeDirectoryId' in category ? - category.homeDirectoryId - : backend.rootDirectoryId(user, organization) - invariant(id, 'Missing root directory') - return id - }, [backend, category, user, organization]) - const [assetTree, setAssetTree] = React.useState(() => { - const rootParentDirectoryId = backendModule.DirectoryId('') - const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath - return AssetTreeNode.fromAsset( - backendModule.createRootDirectoryAsset(rootDirectoryId), - rootParentDirectoryId, - rootParentDirectoryId, - -1, - rootPath, - null, - ) - }) - const [isDraggingFiles, setIsDraggingFiles] = React.useState(false) - const [droppedFilesCount, setDroppedFilesCount] = React.useState(0) - const isCloud = backend.type === backendModule.BackendType.remote - /** Events sent when the asset list was still loading. */ - const queuedAssetListEventsRef = React.useRef([]) - const rootRef = React.useRef(null) - const cleanupRootRef = React.useRef(() => {}) - const mainDropzoneRef = React.useRef(null) - const lastSelectedIdsRef = React.useRef< - backendModule.AssetId | ReadonlySet | null - >(null) - const headerRowRef = React.useRef(null) - const assetTreeRef = React.useRef(assetTree) - const pasteDataRef = React.useRef - > | null>(null) - const nodeMapRef = React.useRef< - ReadonlyMap - >(new Map()) const isAssetContextMenuVisible = category.type !== categoryModule.CategoryType.cloud || user.plan == null || user.plan === backendModule.Plan.solo - const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName) - const rootDirectoryId = React.useMemo( - () => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''), - [backend, user], - ) + const rootDirectoryId = React.useMemo(() => { + const id = + 'homeDirectoryId' in category ? + category.homeDirectoryId + : backend.rootDirectoryId(user, organization) + invariant(id, 'Missing root directory') + return id + }, [backend, user]) const rootParentDirectoryId = backendModule.DirectoryId('') const rootDirectory = React.useMemo( @@ -410,10 +382,17 @@ export default function AssetsTable(props: AssetsTableProps) { ) /** * The expanded directories in the asset tree. + * We don't include the root directory as it might change when a user switches + * between items in sidebar and we don't want to reset the expanded state using useEffect. */ - const [expandedDirectoryIds, setExpandedDirectoryIds] = React.useState< + const [privateExpandedDirectoryIds, setExpandedDirectoryIds] = React.useState< backendModule.DirectoryId[] - >(() => [rootDirectory.id]) + >(() => []) + + const expandedDirectoryIds = React.useMemo( + () => privateExpandedDirectoryIds.concat(rootDirectoryId), + [privateExpandedDirectoryIds, rootDirectoryId], + ) const expandedDirectoryIdsSet = React.useMemo( () => new Set(expandedDirectoryIds), @@ -472,8 +451,8 @@ export default function AssetsTable(props: AssetsTableProps) { { parentId: directoryId, labels: null, - filterBy: CATEGORY_TO_FILTER_BY[category], - recentProjects: category === Category.recent, + filterBy: CATEGORY_TO_FILTER_BY[category.type], + recentProjects: category.type === categoryModule.CategoryType.recent, }, ] as const, queryFn: async ({ queryKey: [, , parentId, params] }) => ({ @@ -527,6 +506,8 @@ export default function AssetsTable(props: AssetsTableProps) { const isLoading = directories.rootDirectory.isLoading const assetTree = React.useMemo(() => { + const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath + // If the root directory is not loaded, then we cannot render the tree. // Return null, and wait for the root directory to load. if (rootDirectoryContent == null) { @@ -536,7 +517,7 @@ export default function AssetsTable(props: AssetsTableProps) { rootParentDirectoryId, rootParentDirectoryId, -1, - backend.rootPath, + rootPath, null, ) } @@ -607,7 +588,7 @@ export default function AssetsTable(props: AssetsTableProps) { rootId, rootId, 0, - `${backend.rootPath}/${content.title}`, + `${rootPath}/${content.title}`, null, content.id, ) @@ -621,7 +602,7 @@ export default function AssetsTable(props: AssetsTableProps) { rootParentDirectoryId, children, -1, - backend.rootPath, + rootPath, null, rootId, ) @@ -632,6 +613,7 @@ export default function AssetsTable(props: AssetsTableProps) { rootParentDirectoryId, backend.rootPath, rootDirectoryId, + category, ]) const filter = React.useMemo(() => { From c12b70529370601783ef34f83ec366e95945c1fd Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Tue, 27 Aug 2024 11:47:03 +0300 Subject: [PATCH 05/17] fix lint --- app/dashboard/src/layouts/AssetsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index 037413d4d679..1d950152aadd 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -368,7 +368,7 @@ export default function AssetsTable(props: AssetsTableProps) { : backend.rootDirectoryId(user, organization) invariant(id, 'Missing root directory') return id - }, [backend, user]) + }, [backend, user, category, organization]) const rootParentDirectoryId = backendModule.DirectoryId('') const rootDirectory = React.useMemo( From a9d69895bd91d046eba69c9a95720193e0881120 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Wed, 28 Aug 2024 15:56:51 +0300 Subject: [PATCH 06/17] Fix copy/delete/move --- app/dashboard/e2e/api.ts | 143 +++-- .../src/components/dashboard/AssetRow.tsx | 524 +----------------- .../dashboard/column/SharedWithColumn.tsx | 4 +- app/dashboard/src/layouts/AssetsTable.tsx | 99 ++++ 4 files changed, 211 insertions(+), 559 deletions(-) diff --git a/app/dashboard/e2e/api.ts b/app/dashboard/e2e/api.ts index 1ba1c79bdce0..0d370525cf44 100644 --- a/app/dashboard/e2e/api.ts +++ b/app/dashboard/e2e/api.ts @@ -60,6 +60,14 @@ export interface MockApi extends Awaited> {} // eslint-disable-next-line no-restricted-syntax export const mockApi: (params: MockParams) => Promise = mockApiInternal +/** + * Wait for a given number of milliseconds. + */ +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +function wait(ms: number = 5_000) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + /** Add route handlers for the mock API to a page. */ // This syntax is required for Playwright to work properly. // eslint-disable-next-line no-restricted-syntax @@ -454,7 +462,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // === Endpoints returning arrays === - await get(remoteBackendPaths.LIST_DIRECTORY_PATH + '*', (_route, request) => { + await get(remoteBackendPaths.LIST_DIRECTORY_PATH + '*', async (_route, request) => { /** The type for the search query for this endpoint. */ interface Query { /* eslint-disable @typescript-eslint/naming-convention */ @@ -504,26 +512,30 @@ 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 } + + await wait() + 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 + '*', async () => { + await wait() + return { files: [] } satisfies remoteBackend.ListFilesResponseBody + }) + await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', async () => { + await wait() + return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody + }) + await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', async () => { + await wait() + return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody + }) + await get(remoteBackendPaths.LIST_TAGS_PATH + '*', async () => { + await wait() + return { tags: labels } satisfies remoteBackend.ListTagsResponseBody + }) await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => { if (currentUser != null) { + await wait() return { users } satisfies remoteBackend.ListUsersResponseBody } else { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) @@ -531,6 +543,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => { + await wait() + await route.fulfill({ json: [] }) }) await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => ({ @@ -553,29 +567,35 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // === Endpoints with dummy implementations === - await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => { - const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') - const project = assetMap.get(projectId) - if (!project?.projectState) { - throw new Error('Attempting to get a project that does not exist.') - } else { - return { - organizationId: defaultOrganizationId, - projectId: projectId, - name: 'example project name', - state: project.projectState, - packageName: 'Project_root', - // eslint-disable-next-line @typescript-eslint/naming-convention - ide_version: null, - // eslint-disable-next-line @typescript-eslint/naming-convention - engine_version: { - value: '2023.2.1-nightly.2023.9.29', - lifecycle: backend.VersionLifecycle.development, - }, - address: backend.Address('ws://localhost/'), - } satisfies backend.ProjectRaw - } - }) + await get( + remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), + async (_route, request) => { + const projectId = backend.ProjectId( + request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '', + ) + const project = assetMap.get(projectId) + await wait() + if (!project?.projectState) { + throw new Error('Attempting to get a project that does not exist.') + } else { + return { + organizationId: defaultOrganizationId, + projectId: projectId, + name: 'example project name', + state: project.projectState, + packageName: 'Project_root', + // eslint-disable-next-line @typescript-eslint/naming-convention + ide_version: null, + // eslint-disable-next-line @typescript-eslint/naming-convention + engine_version: { + value: '2023.2.1-nightly.2023.9.29', + lifecycle: backend.VersionLifecycle.development, + }, + address: backend.Address('ws://localhost/'), + } satisfies backend.ProjectRaw + } + }, + ) // === Endpoints returning `void` === @@ -584,6 +604,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { interface Body { readonly parentDirectoryId: backend.DirectoryId } + + await wait() + 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 @@ -622,20 +645,25 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await get(remoteBackendPaths.INVITATION_PATH + '*', async (route) => { + await wait() await route.fulfill({ json: { invitations: [] } satisfies backend.ListInvitationsResponseBody, }) }) await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => { + await wait() await route.fulfill() }) await post(remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async (route) => { + await wait() await route.fulfill() }) await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route) => { + await wait() await route.fulfill() }) await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => { + await wait() const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') const project = assetMap.get(projectId) if (project?.projectState) { @@ -644,6 +672,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill() }) await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => { + await wait() const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') const project = assetMap.get(projectId) if (project?.projectState) { @@ -652,15 +681,18 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill() }) await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route) => { + await wait() await route.fulfill() }) await post(remoteBackendPaths.POST_LOG_EVENT_PATH, async (route) => { + await wait() await route.fulfill() }) // === Entity creation endpoints === await put(remoteBackendPaths.UPLOAD_USER_PICTURE_PATH + '*', async (route, request) => { + await wait() const content = request.postData() if (content != null) { currentProfilePicture = content @@ -671,6 +703,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await put(remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH + '*', async (route, request) => { + await wait() const content = request.postData() if (content != null) { currentOrganizationProfilePicture = content @@ -680,7 +713,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return } }) - await post(remoteBackendPaths.UPLOAD_FILE_PATH + '*', (_route, request) => { + await post(remoteBackendPaths.UPLOAD_FILE_PATH + '*', async (_route, request) => { + await wait() /** The type for the JSON request payload for this endpoint. */ interface SearchParams { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -700,6 +734,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => { + await wait() // 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() @@ -709,7 +744,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // === Other endpoints === - await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => { + await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), async (_route, request) => { + await wait() const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '' // The type of the body sent by this app is statically known. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -724,6 +760,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => { + await wait() const assetId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] ?? '' /** The type for the JSON request payload for this endpoint. */ interface Body { @@ -748,6 +785,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return json }) await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => { + await wait() const directoryId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] ?? '' // The type of the body sent by this app is statically known. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -767,6 +805,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { + await wait() const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '' // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. @@ -774,6 +813,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => { + await wait() /** The type for the JSON request payload for this endpoint. */ interface Body { readonly assetId: backend.AssetId @@ -785,6 +825,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) await post(remoteBackendPaths.CREATE_USER_PATH + '*', async (route, request) => { + await wait() // The type of the body sent by this app is statically known. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body: backend.CreateUserRequestBody = await request.postDataJSON() @@ -806,6 +847,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill({ json: currentUser }) }) await put(remoteBackendPaths.UPDATE_CURRENT_USER_PATH + '*', async (_route, request) => { + await wait() // The type of the body sent by this app is statically known. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body: backend.UpdateUserRequestBody = await request.postDataJSON() @@ -813,8 +855,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { currentUser = { ...currentUser, name: body.username } } }) - await get(remoteBackendPaths.USERS_ME_PATH + '*', () => currentUser) + await get(remoteBackendPaths.USERS_ME_PATH + '*', async () => { + await wait() + return currentUser + }) await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => { + await wait() // The type of the body sent by this app is statically known. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body: backend.UpdateOrganizationRequestBody = await request.postDataJSON() @@ -833,18 +879,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await get(remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async (route) => { + await wait() await route.fulfill({ json: currentOrganization, // eslint-disable-next-line @typescript-eslint/no-magic-numbers status: currentOrganization == null ? 404 : 200, }) }) - await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => { + await post(remoteBackendPaths.CREATE_TAG_PATH + '*', async (route) => { + await wait() // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body: backend.CreateTagRequestBody = route.request().postDataJSON() return addLabel(body.value, body.color) }) - await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => { + await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', async (_route, request) => { + await wait() // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body: backend.CreateProjectRequestBody = request.postDataJSON() const title = body.projectName @@ -879,7 +928,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) return json }) - await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => { + await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', async (_route, request) => { + await wait() // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body: backend.CreateDirectoryRequestBody = request.postDataJSON() const title = body.title @@ -910,6 +960,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await page.route('*', async (route) => { + await wait() if (!isOnline) { await route.abort('connectionfailed') } diff --git a/app/dashboard/src/components/dashboard/AssetRow.tsx b/app/dashboard/src/components/dashboard/AssetRow.tsx index 7028cd8f155a..66ef671b5716 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -1,18 +1,14 @@ /** @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, useListUserGroups, useListUsers } 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' @@ -35,18 +31,13 @@ import FocusRing from '#/components/styled/FocusRing' import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal' import * as backendModule from '#/services/Backend' -import * as localBackend from '#/services/LocalBackend' import { createGetProjectDetailsQuery } from '#/hooks/projectHooks' import type * as assetTreeNode from '#/utilities/AssetTreeNode' -import * as dateTime from '#/utilities/dateTime' -import * as 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' @@ -96,9 +87,17 @@ 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, + } = state const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state - const { visibilities, category } = state + const { visibilities } = state const [item, setItem] = React.useState(rawItem) const driveStore = useDriveStore() @@ -115,14 +114,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 users = useListUsers(backend) - const userGroups = useListUserGroups(backend) const [isDraggedOver, setIsDraggedOver] = React.useState(false) const rootRef = React.useRef(null) const dragOverTimeoutHandle = React.useRef(null) @@ -137,7 +132,7 @@ export default function AssetRow(props: AssetRowProps) { readonly nodeMap: WeakRef> readonly parentKeys: Map } | null>(null) - const isCloud = categoryModule.isCloudCategory(category) + const outerVisibility = visibilities.get(item.key) const visibility = outerVisibility == null || outerVisibility === Visibility.visible ? @@ -145,26 +140,6 @@ export default function AssetRow(props: AssetRowProps) { : 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 { data: projectState } = useQuery({ // This is SAFE, as `isOpened` is only true for projects. // eslint-disable-next-line no-restricted-syntax @@ -204,158 +179,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), - ) - } 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 }) @@ -365,65 +188,11 @@ export default function AssetRow(props: AssetRowProps) { 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) - } + doDeleteRaw(forever, item.item) }, - [ - 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( { - if (state.category.type === categoryModule.CategoryType.trash) { - switch (event.type) { - case AssetEventType.deleteForever: { - if (event.ids.has(item.key)) { - await doDelete(true) - } - break - } - case AssetEventType.restore: { - if (event.ids.has(item.key)) { - await doRestore() - } - break - } - default: { - break - } - } - } else { - switch (event.type) { - 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) - } - break - } - case AssetEventType.cancelCut: { - if (event.ids.has(item.key)) { - setInsertionVisibility(Visibility.visible) - } - break - } - case AssetEventType.move: { - if (event.ids.has(item.key)) { - setInsertionVisibility(Visibility.visible) - await doMove(event.newParentKey, event.newParentId) - } - break - } - case AssetEventType.delete: { - if (event.ids.has(item.key)) { - await doDelete(false) - } - break - } - case AssetEventType.deleteForever: { - if (event.ids.has(item.key)) { - await doDelete(true) - } - break - } - case AssetEventType.restore: { - if (event.ids.has(item.key)) { - await doRestore() - } - break - } - case AssetEventType.download: - case AssetEventType.downloadSelected: { - if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) { - if (isCloud) { - switch (asset.type) { - case backendModule.AssetType.project: { - try { - const details = await getProjectDetailsMutation.mutateAsync([ - asset.id, - asset.parentId, - asset.title, - ]) - if (details.url != null) { - await backend.download(details.url, `${asset.title}.enso-project`) - } else { - const error: unknown = getText('projectHasNoSourceFilesPhrase') - toastAndLog('downloadProjectError', error, asset.title) - } - } catch (error) { - toastAndLog('downloadProjectError', error, asset.title) - } - break - } - case backendModule.AssetType.file: { - try { - const details = await getFileDetailsMutation.mutateAsync([ - asset.id, - asset.title, - ]) - if (details.url != null) { - await backend.download(details.url, asset.title) - } else { - const error: unknown = getText('fileNotFoundPhrase') - toastAndLog('downloadFileError', error, asset.title) - } - } catch (error) { - toastAndLog('downloadFileError', error, asset.title) - } - break - } - case backendModule.AssetType.datalink: { - try { - const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title]) - const fileName = `${asset.title}.datalink` - download.download( - URL.createObjectURL( - new File([JSON.stringify(value)], fileName, { - type: 'application/json+x-enso-data-link', - }), - ), - fileName, - ) - } catch (error) { - toastAndLog('downloadDatalinkError', error, asset.title) - } - break - } - default: { - toastAndLog('downloadInvalidTypeError') - break - } - } - } else { - if (asset.type === backendModule.AssetType.project) { - const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id - const uuid = localBackend.extractTypeAndId(asset.id).id - const queryString = new URLSearchParams({ projectsDirectory }).toString() - await backend.download( - `./api/project-manager/projects/${uuid}/enso-project?${queryString}`, - `${asset.title}.enso-project`, - ) - } - } - } - break - } - case AssetEventType.removeSelf: { - // This is not triggered from the asset list, so it uses `item.id` instead of `key`. - if (event.id === asset.id && user.isEnabled) { - setInsertionVisibility(Visibility.hidden) - try { - await createPermissionMutation.mutateAsync([ - { - action: null, - resourceId: asset.id, - actorsIds: [user.userId], - }, - ]) - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - } catch (error) { - setInsertionVisibility(Visibility.visible) - toastAndLog(null, error) - } - } - break - } - case AssetEventType.temporarilyAddLabels: { - const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === labels && - oldRowState.temporarilyRemovedLabels === set.EMPTY_SET - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: labels, - temporarilyRemovedLabels: set.EMPTY_SET, - }), - ) - break - } - case AssetEventType.temporarilyRemoveLabels: { - const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === set.EMPTY_SET && - oldRowState.temporarilyRemovedLabels === labels - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: set.EMPTY_SET, - temporarilyRemovedLabels: labels, - }), - ) - break - } - case AssetEventType.addLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(item.key) && - (labels == null || [...event.labelNames].some((label) => !labels.includes(label))) - ) { - const newLabels = [ - ...(labels ?? []), - ...[...event.labelNames].filter((label) => labels?.includes(label) !== true), - ] - setAsset(object.merger({ labels: newLabels })) - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - setAsset(object.merger({ labels })) - toastAndLog(null, error) - } - } - break - } - case AssetEventType.removeLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(item.key) && - labels != null && - [...event.labelNames].some((label) => labels.includes(label)) - ) { - const newLabels = labels.filter((label) => !event.labelNames.has(label)) - setAsset(object.merger({ labels: newLabels })) - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - setAsset(object.merger({ labels })) - toastAndLog(null, error) - } - } - break - } - case AssetEventType.deleteLabel: { - setAsset((oldAsset) => { - const oldLabels = oldAsset.labels ?? [] - const labels: backendModule.LabelName[] = [] - - for (const label of oldLabels) { - if (label !== event.labelName) { - labels.push(label) - } - } - - return oldLabels.length !== labels.length ? - object.merge(oldAsset, { labels }) - : oldAsset - }) - break - } - case AssetEventType.setItem: { - if (asset.id === event.id) { - setAsset(event.valueOrUpdater) - } - break - } - default: - break - } - } - }, item.initialAssetEvents) - const clearDragState = React.useCallback(() => { setIsDraggedOver(false) setRowState((oldRowState) => diff --git a/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx b/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index a62a547ed62e..10a9e885bbeb 100644 --- a/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -71,9 +71,9 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { return (
- {(asset.permissions ?? []).map((other) => ( + {(asset.permissions ?? []).map((other, idx) => ( void + readonly doDelete: (forever: boolean, item: backendModule.AnyAsset) => void } /** Data associated with a {@link AssetRow}, used for rendering. */ @@ -437,6 +438,31 @@ export default function AssetsTable(props: AssetsTableProps) { const getProjectDetailsMutation = useMutation( backendMutationOptions(backend, 'getProjectDetails'), ) + const copyAssetMutation = useMutation( + backendMutationOptions(backend, 'copyAsset', { + meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, + }), + ) + const deleteAssetMutation = useMutation( + backendMutationOptions(backend, 'deleteAsset', { + meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, + }), + ) + const undoDeleteAssetMutation = useMutation( + backendMutationOptions(backend, 'undoDeleteAsset', { + meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, + }), + ) + const updateAssetMutation = useMutation( + backendMutationOptions(backend, 'updateAsset', { + meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, + }), + ) + const closeProjectMutation = useMutation( + backendMutationOptions(backend, 'closeProject', { + meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, + }), + ) const directories = useQueries({ // We query only expanded directories, as we don't want to load the data for directories that are not visible. @@ -1203,6 +1229,65 @@ export default function AssetsTable(props: AssetsTableProps) { }, ) + const doCopyOnBackend = useEventCallback( + async (newParentId: backendModule.DirectoryId | null, asset: backendModule.AnyAsset) => { + try { + newParentId ??= rootDirectoryId + + await copyAssetMutation.mutateAsync([ + asset.id, + newParentId, + asset.title, + nodeMapRef.current.get(newParentId)?.item.title ?? '(unknown)', + ]) + } catch (error) { + toastAndLog('copyAssetError', error, asset.title) + } + }, + ) + + const doMove = useEventCallback( + async (newParentId: backendModule.DirectoryId | null, asset: backendModule.AnyAsset) => { + try { + await updateAssetMutation.mutateAsync([ + asset.id, + { parentDirectoryId: newParentId ?? rootDirectoryId, description: null }, + asset.title, + ]) + } catch (error) { + toastAndLog('moveAssetError', error, asset.title) + } + }, + ) + + const doDelete = useEventCallback(async (forever = false, asset: backendModule.AnyAsset) => { + 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: asset.id, + }) + } + try { + dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: asset.id }) + if ( + asset.type === backendModule.AssetType.project && + backend.type === backendModule.BackendType.local + ) { + try { + await closeProjectMutation.mutateAsync([asset.id, asset.title]) + } catch { + // Ignored. The project was already closed. + } + } + await deleteAssetMutation.mutateAsync([asset.id, { force: forever }, asset.title]) + } catch (error) { + toastAndLog('deleteAssetError', error, asset.title) + } + }) + const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial) const [keyboardSelectedIndex, setKeyboardSelectedIndex] = React.useState(null) const mostRecentlySelectedIndexRef = React.useRef(null) @@ -1878,6 +1963,7 @@ export default function AssetsTable(props: AssetsTableProps) { break } case AssetListEventType.insertAssets: { + console.log('insertAssets', event) insertAssets(event.assets, event.parentId) break } @@ -1946,15 +2032,26 @@ export default function AssetsTable(props: AssetsTableProps) { } case AssetListEventType.copy: { insertAssets(event.items, event.newParentId) + + for (const item of event.items) { + void doCopyOnBackend(event.newParentId, item) + } break } case AssetListEventType.move: { deleteAsset(event.key) insertAssets([event.item], event.newParentId) + + void doMove(event.newParentId, event.item) + break } case AssetListEventType.delete: { deleteAsset(event.key) + const asset = nodeMapRef.current.get(event.key)?.item + if (asset) { + void doDelete(false, asset) + } break } case AssetListEventType.emptyTrash: { @@ -2119,6 +2216,7 @@ export default function AssetsTable(props: AssetsTableProps) { doCopy, doCut, doPaste, + doDelete, }), [ backend, @@ -2133,6 +2231,7 @@ export default function AssetsTable(props: AssetsTableProps) { doCopy, doCut, doPaste, + doDelete, hideColumn, setAssetPanelProps, setIsAssetPanelTemporarilyVisible, From 7599cf72956976b4c67c2d9ef581aa9e49d0872b Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 29 Aug 2024 08:47:01 +0300 Subject: [PATCH 07/17] Fix lint --- app/dashboard/src/components/dashboard/AssetRow.tsx | 4 ++-- app/dashboard/src/layouts/AssetsTable.tsx | 8 +------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app/dashboard/src/components/dashboard/AssetRow.tsx b/app/dashboard/src/components/dashboard/AssetRow.tsx index 66ef671b5716..fb97a8f1405e 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -187,8 +187,8 @@ export default function AssetRow(props: AssetRowProps) { }, [item, isSoleSelected, backend, setAssetPanelProps, setIsAssetPanelTemporarilyVisible]) const doDelete = React.useCallback( - async (forever = false) => { - doDeleteRaw(forever, item.item) + (forever = false) => { + void doDeleteRaw(forever, item.item) }, [doDeleteRaw, item.item], ) diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index 2caa429088cd..3d4ef34f0376 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -274,7 +274,7 @@ export interface AssetsTableState { newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId, ) => void - readonly doDelete: (forever: boolean, item: backendModule.AnyAsset) => void + readonly doDelete: (forever: boolean, item: backendModule.AnyAsset) => Promise } /** Data associated with a {@link AssetRow}, used for rendering. */ @@ -448,11 +448,6 @@ export default function AssetsTable(props: AssetsTableProps) { meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, }), ) - const undoDeleteAssetMutation = useMutation( - backendMutationOptions(backend, 'undoDeleteAsset', { - meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, - }), - ) const updateAssetMutation = useMutation( backendMutationOptions(backend, 'updateAsset', { meta: { invalidates: [['assetVersions'], ['listDirectory', backend.type]] }, @@ -1963,7 +1958,6 @@ export default function AssetsTable(props: AssetsTableProps) { break } case AssetListEventType.insertAssets: { - console.log('insertAssets', event) insertAssets(event.assets, event.parentId) break } From 596e3f851e34cd181f6c81d225ee7e64ba802ef5 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 29 Aug 2024 09:01:45 +0300 Subject: [PATCH 08/17] Fixes --- .../src/components/dashboard/AssetRow.tsx | 3 +- app/dashboard/src/layouts/AssetsTable.tsx | 65 +++++++++---------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/app/dashboard/src/components/dashboard/AssetRow.tsx b/app/dashboard/src/components/dashboard/AssetRow.tsx index 7f2faf208b57..4cddd7e944e1 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -32,7 +32,6 @@ import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal' import * as backendModule from '#/services/Backend' import { createGetProjectDetailsQuery } from '#/hooks/projectHooks' -import { isCloudCategory } from '#/layouts/CategorySwitcher/Category' import type * as assetTreeNode from '#/utilities/AssetTreeNode' import * as drag from '#/utilities/drag' import * as eventModule from '#/utilities/event' @@ -188,7 +187,7 @@ export default function AssetRow(props: AssetRowProps) { const doDelete = React.useCallback( (forever = false) => { - void doDeleteRaw(forever, item.item) + void doDeleteRaw(item.item, forever) }, [doDeleteRaw, item.item], ) diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index 0871d0c41f17..7b8125572342 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -80,7 +80,6 @@ import * as projectManager from '#/services/ProjectManager' import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend' import { ErrorDisplay } from '#/components/ErrorBoundary' -import * as array from '#/utilities/array' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useFeatureFlag } from '#/providers/FeatureFlagsProvider' import type * as assetQuery from '#/utilities/AssetQuery' @@ -275,7 +274,7 @@ export interface AssetsTableState { newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId, ) => void - readonly doDelete: (forever: boolean, item: backendModule.AnyAsset) => Promise + readonly doDelete: (item: backendModule.AnyAsset, forever: boolean) => Promise } /** Data associated with a {@link AssetRow}, used for rendering. */ @@ -360,9 +359,7 @@ export default function AssetsTable(props: AssetsTableProps) { const organization = organizationQuery.data const isAssetContextMenuVisible = - category.type !== 'cloud' || - user.plan == null || - user.plan === backendModule.Plan.solo + category.type !== 'cloud' || user.plan == null || user.plan === backendModule.Plan.solo const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName) const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory') @@ -371,7 +368,7 @@ export default function AssetsTable(props: AssetsTableProps) { const id = 'homeDirectoryId' in category ? category.homeDirectoryId - : backend.rootDirectoryId(user, organization, localRootPath) + : backend.rootDirectoryId(user, organization, localRootPath) invariant(id, 'Missing root directory') return id }, [category, backend, user, organization, localRootDirectory]) @@ -478,7 +475,7 @@ export default function AssetsTable(props: AssetsTableProps) { parentId: directoryId, labels: null, filterBy: CATEGORY_TO_FILTER_BY[category.type], - recentProjects: category.type === categoryModule.CategoryType.recent, + recentProjects: category.type === 'recent', }, ] as const, queryFn: async ({ queryKey: [, , parentId, params] }) => ({ @@ -1260,33 +1257,35 @@ export default function AssetsTable(props: AssetsTableProps) { }, ) - const doDelete = useEventCallback(async (forever = false, asset: backendModule.AnyAsset) => { - 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: asset.id, - }) - } - try { - dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: asset.id }) - if ( - asset.type === backendModule.AssetType.project && - backend.type === backendModule.BackendType.local - ) { - try { - await closeProjectMutation.mutateAsync([asset.id, asset.title]) - } catch { - // Ignored. The project was already closed. + const doDelete = useEventCallback( + async (asset: backendModule.AnyAsset, forever: boolean = false) => { + 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: asset.id, + }) + } + try { + dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: asset.id }) + if ( + asset.type === backendModule.AssetType.project && + backend.type === backendModule.BackendType.local + ) { + try { + await closeProjectMutation.mutateAsync([asset.id, asset.title]) + } catch { + // Ignored. The project was already closed. + } } + await deleteAssetMutation.mutateAsync([asset.id, { force: forever }, asset.title]) + } catch (error) { + toastAndLog('deleteAssetError', error, asset.title) } - await deleteAssetMutation.mutateAsync([asset.id, { force: forever }, asset.title]) - } catch (error) { - toastAndLog('deleteAssetError', error, asset.title) - } - }) + }, + ) const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial) const [keyboardSelectedIndex, setKeyboardSelectedIndex] = React.useState(null) @@ -2049,7 +2048,7 @@ export default function AssetsTable(props: AssetsTableProps) { deleteAsset(event.key) const asset = nodeMapRef.current.get(event.key)?.item if (asset) { - void doDelete(false, asset) + void doDelete(asset, false) } break } From a09571489e59ee32bd51b2d4ec1bc071baf465da Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 30 Aug 2024 13:16:06 +0300 Subject: [PATCH 09/17] Fixes after merge --- .../components/AriaComponents/Form/Form.tsx | 7 ++---- .../AriaComponents/Form/components/types.ts | 6 ++--- .../AriaComponents/Form/components/useForm.ts | 24 +++++++++---------- .../components/AriaComponents/Form/types.ts | 6 ++--- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/app/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/dashboard/src/components/AriaComponents/Form/Form.tsx index db5f5d9deefd..0c0e10048870 100644 --- a/app/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -163,11 +163,8 @@ export const Form = forwardRef(function Form( ) -}) as unknown as (< - Schema extends components.TSchema ->( - props: React.RefAttributes & - types.FormProps, +}) as unknown as (( + props: React.RefAttributes & types.FormProps, ) => React.JSX.Element) & { /* eslint-disable @typescript-eslint/naming-convention */ schema: typeof components.schema diff --git a/app/dashboard/src/components/AriaComponents/Form/components/types.ts b/app/dashboard/src/components/AriaComponents/Form/components/types.ts index 0ab5a30a5da7..2162e80e08f9 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/types.ts @@ -45,11 +45,11 @@ export interface UseFormProps /** * Register function for a form field. */ -export type UseFormRegister> = < +export type UseFormRegister = < TFieldName extends FieldPath = FieldPath, >( name: TFieldName, - options?: reactHookForm.RegisterOptions, + options?: reactHookForm.RegisterOptions, TFieldName>, // eslint-disable-next-line no-restricted-syntax ) => UseFormRegisterReturn @@ -75,7 +75,7 @@ export interface UseFormRegisterReturn< */ export interface UseFormReturn extends reactHookForm.UseFormReturn, unknown, TransformedValues> { - readonly register: UseFormRegister> + readonly register: UseFormRegister } /** diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts index 76e8c30bd149..9d20e04645c0 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts @@ -53,32 +53,32 @@ export function useForm( `, ) - 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 const formInstance = reactHookForm.useForm< - types.FieldValues - , unknown, + types.FieldValues, + unknown, types.TransformedValues >({ ...options, resolver: zodResolver.zodResolver(computedSchema), }) - const register: types.UseFormRegister> = (name, opts) => { + const register: types.UseFormRegister = (name, opts) => { const registered = formInstance.register(name, opts) - const onChange: types.UseFormRegisterReturn>['onChange'] = (value) => + const onChange: types.UseFormRegisterReturn['onChange'] = (value) => registered.onChange(mapValueOnEvent(value)) - const onBlur: types.UseFormRegisterReturn>['onBlur'] = (value) => + const onBlur: types.UseFormRegisterReturn['onBlur'] = (value) => registered.onBlur(mapValueOnEvent(value)) - const result: types.UseFormRegisterReturn, typeof name> = { + const result: types.UseFormRegisterReturn = { ...registered, ...(registered.disabled != null ? { isDisabled: registered.disabled } : {}), ...(registered.required != null ? { isRequired: registered.required } : {}), @@ -94,7 +94,7 @@ export function useForm( ...formInstance, control: { ...formInstance.control, register }, register, - } satisfies types.UseFormReturn, types.TransformedValues> + } satisfies types.UseFormReturn } } diff --git a/app/dashboard/src/components/AriaComponents/Form/types.ts b/app/dashboard/src/components/AriaComponents/Form/types.ts index 62589e47d017..28fe075b093e 100644 --- a/app/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/types.ts @@ -43,14 +43,14 @@ interface BaseFormProps readonly style?: | React.CSSProperties | ((props: components.UseFormReturn) => React.CSSProperties) - readonly children: React.ReactNode + readonly children: + | React.ReactNode | (( props: components.UseFormReturn & { readonly form: components.UseFormReturn }, ) => React.ReactNode) - readonly formRef?: React.MutableRefObject< - components.UseFormReturn> + readonly formRef?: React.MutableRefObject> readonly className?: string | ((props: components.UseFormReturn) => string) From 1b36b1f602b2e72f35c3fd6921d2192863ba2f22 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 30 Aug 2024 13:22:44 +0300 Subject: [PATCH 10/17] Fixes x2 --- .../AriaComponents/Switch/Switch.tsx | 50 ++++--------------- .../src/components/Devtools/EnsoDevtools.tsx | 22 ++++---- 2 files changed, 24 insertions(+), 48 deletions(-) diff --git a/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx b/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx index 96c91a1e632c..07827c44d07d 100644 --- a/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx +++ b/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx @@ -9,33 +9,21 @@ import { type SwitchProps as AriaSwitchProps, } from '#/components/aria' import { mergeRefs } from '#/utilities/mergeRefs' -import type { CSSProperties, ForwardedRef, ReactElement, RefAttributes } from 'react' -import { forwardRef, useRef } from 'react' +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 FieldValues, - type TSchema, -} from '../Form' +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, - TFieldValues extends FieldValues, - TFieldName extends FieldPath, - TTransformedValues extends FieldValues | undefined = undefined, -> extends FieldStateProps< +export interface SwitchProps> + extends FieldStateProps< Omit & { value: boolean }, Schema, - TFieldValues, - TFieldName, - TTransformedValues + TFieldName >, FieldProps, Omit, 'disabled' | 'invalid'> { @@ -77,13 +65,8 @@ export const SWITCH_STYLES = tv({ // eslint-disable-next-line no-restricted-syntax export const Switch = forwardRef(function Switch< Schema extends TSchema, - TFieldValues extends FieldValues, - TFieldName extends FieldPath, - TTransformedValues extends FieldValues | undefined = undefined, ->( - props: SwitchProps, - ref: ForwardedRef, -) { + TFieldName extends FieldPath, +>(props: SwitchProps, ref: ForwardedRef) { const { label, isDisabled = false, @@ -120,10 +103,7 @@ export const Switch = forwardRef(function Switch< background, label: labelStyle, switch: switchStyles, - } = SWITCH_STYLES({ - size, - disabled: fieldProps.disabled, - }) + } = SWITCH_STYLES({ size, disabled: fieldProps.disabled }) return ( ) -}) as < - Schema extends TSchema, - TFieldValues extends FieldValues, - TFieldName extends FieldPath, - TTransformedValues extends FieldValues | undefined = undefined, ->( - props: RefAttributes & - SwitchProps, -) => ReactElement +}) diff --git a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx index b3a544b3c3fa..3b1e74fdb1e2 100644 --- a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx +++ b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx @@ -27,6 +27,16 @@ import { import * as ariaComponents from '#/components/AriaComponents' import Portal from '#/components/Portal' +import { + Button, + ButtonGroup, + Form, + Popover, + Radio, + RadioGroup, + Separator, + Text, +} from '#/components/AriaComponents' import { FEATURE_FLAGS_SCHEMA, useFeatureFlags, @@ -36,7 +46,6 @@ 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' -import { Button, ButtonGroup, Form, Popover, Radio, RadioGroup, Separator, Text } from '#/components/AriaComponents' /** * A component that provides a UI for toggling paywall features. @@ -76,9 +85,7 @@ export function EnsoDevtools() { {session?.type === UserSessionType.full && ( <> - - {getText('ensoDevtoolsPlanSelectSubtitle')} - + {getText('ensoDevtoolsPlanSelectSubtitle')}
- - + +