diff --git a/app/dashboard/e2e/api.ts b/app/dashboard/e2e/api.ts index 1ba1c79bdce0..23730aa1b4a8 100644 --- a/app/dashboard/e2e/api.ts +++ b/app/dashboard/e2e/api.ts @@ -504,24 +504,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { (a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type], ) const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets } + return json }) - await get( - remoteBackendPaths.LIST_FILES_PATH + '*', - () => ({ files: [] }) satisfies remoteBackend.ListFilesResponseBody, - ) - await get( - remoteBackendPaths.LIST_PROJECTS_PATH + '*', - () => ({ projects: [] }) satisfies remoteBackend.ListProjectsResponseBody, - ) - await get( - remoteBackendPaths.LIST_SECRETS_PATH + '*', - () => ({ secrets: [] }) satisfies remoteBackend.ListSecretsResponseBody, - ) - await get( - remoteBackendPaths.LIST_TAGS_PATH + '*', - () => ({ tags: labels }) satisfies remoteBackend.ListTagsResponseBody, - ) + await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => { + return { files: [] } satisfies remoteBackend.ListFilesResponseBody + }) + await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => { + return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody + }) + await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => { + return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody + }) + await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => { + return { tags: labels } satisfies remoteBackend.ListTagsResponseBody + }) await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => { if (currentUser != null) { return { users } satisfies remoteBackend.ListUsersResponseBody @@ -584,6 +581,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { interface Body { readonly parentDirectoryId: backend.DirectoryId } + const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1] // eslint-disable-next-line no-restricted-syntax const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null @@ -605,7 +603,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const body: Body = await request.postDataJSON() const parentId = body.parentDirectoryId // Can be any asset ID. - const id = backend.DirectoryId(uniqueString.uniqueString()) + const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) const json: backend.CopyAssetResponse = { asset: { id, @@ -621,6 +619,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill({ json }) } }) + await get(remoteBackendPaths.INVITATION_PATH + '*', async (route) => { await route.fulfill({ json: { invitations: [] } satisfies backend.ListInvitationsResponseBody, @@ -695,7 +694,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const searchParams: SearchParams = Object.fromEntries( new URL(request.url()).searchParams.entries(), ) as never - const file = createFile(searchParams.file_name) + + const file = addFile(searchParams.file_name) + return { path: '', id: file.id, project: null } satisfies backend.FileInfo }) @@ -703,7 +704,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // The type of the body sent by this app is statically known. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body: backend.CreateSecretRequestBody = await request.postDataJSON() - const secret = createSecret(body.name) + const secret = addSecret(body.name) return secret.id }) @@ -721,6 +722,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { if (body.description != null) { object.unsafeMutable(asset).description = body.description } + + if (body.parentDirectoryId != null) { + object.unsafeMutable(asset).parentId = body.parentDirectoryId + } } }) await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => { @@ -813,7 +818,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { currentUser = { ...currentUser, name: body.username } } }) - await get(remoteBackendPaths.USERS_ME_PATH + '*', () => currentUser) + await get(remoteBackendPaths.USERS_ME_PATH + '*', () => { + return currentUser + }) await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => { // The type of the body sent by this app is statically known. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment diff --git a/app/dashboard/e2e/copy.spec.ts b/app/dashboard/e2e/copy.spec.ts index 373edefcc272..feb5f700dc96 100644 --- a/app/dashboard/e2e/copy.spec.ts +++ b/app/dashboard/e2e/copy.spec.ts @@ -23,7 +23,7 @@ test.test('copy', ({ page }) => .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(3) await test.expect(rows.nth(2)).toBeVisible() - await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/) + await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) @@ -46,7 +46,7 @@ test.test('copy (keyboard)', ({ page }) => .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(3) await test.expect(rows.nth(2)).toBeVisible() - await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/) + await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) @@ -69,7 +69,7 @@ test.test('move', ({ page }) => .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(2) await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/) + await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) @@ -88,7 +88,7 @@ test.test('move (drag)', ({ page }) => .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(2) await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/) + await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) @@ -129,7 +129,7 @@ test.test('move (keyboard)', ({ page }) => .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(2) await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/) + await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) @@ -164,11 +164,11 @@ test.test('duplicate', ({ page }) => .driveTable.rightClickRow(0) .contextMenu.duplicate() .driveTable.withRows(async (rows) => { - // Assets: [0: New Project 1 (copy), 1: New Project 1] + // Assets: [0: New Project 1, 1: New Project 1 (copy)] await test.expect(rows).toHaveCount(2) await test.expect(actions.locateContextMenus(page)).not.toBeVisible() - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/) + await test.expect(rows.nth(1)).toBeVisible() + await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) }), ) @@ -184,7 +184,7 @@ test.test('duplicate (keyboard)', ({ page }) => .driveTable.withRows(async (rows) => { // Assets: [0: New Project 1 (copy), 1: New Project 1] await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/) + await test.expect(rows.nth(1)).toBeVisible() + await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) }), ) diff --git a/app/dashboard/e2e/driveView.spec.ts b/app/dashboard/e2e/driveView.spec.ts index 49e614f746b3..bdb80e765f8a 100644 --- a/app/dashboard/e2e/driveView.spec.ts +++ b/app/dashboard/e2e/driveView.spec.ts @@ -33,7 +33,7 @@ test.test('drive view', ({ page }) => // user that project creation may take a while. Previously opened projects are stopped when the // new project is created. .driveTable.withRows(async (rows) => { - await actions.locateStopProjectButton(rows.nth(0)).click() + await actions.locateStopProjectButton(rows.nth(1)).click() }) // Project context menu .driveTable.rightClickRow(0) diff --git a/app/dashboard/src/App.tsx b/app/dashboard/src/App.tsx index f23bef9b5d06..2fbba4abad1c 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, { useLocalBackend } from '#/providers/BackendProvider' import DriveProvider from '#/providers/DriveProvider' -import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider' import { useHttpClient } from '#/providers/HttpClientProvider' import InputBindingsProvider from '#/providers/InputBindingsProvider' import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider' @@ -91,10 +90,11 @@ import * as appBaseUrl from '#/utilities/appBaseUrl' import * as eventModule from '#/utilities/event' import LocalStorage from '#/utilities/LocalStorage' import * as object from '#/utilities/object' +import { Path } from '#/utilities/path' import { useInitAuthService } from '#/authentication/service' import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal' -import { Path } from '#/utilities/path' +import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider' // ============================ // === Global configuration === @@ -492,7 +492,7 @@ function AppRouter(props: AppRouterProps) { ) return ( - + - + + + )} @@ -527,7 +529,7 @@ 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 a1621671f0cf..0c0e10048870 100644 --- a/app/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -14,23 +14,11 @@ import * as aria from '#/components/aria' import * as errorUtils from '#/utilities/error' import { forwardRef } from '#/utilities/react' -import type { Mutable } from 'enso-common/src/utilities/data/object' import * as dialog from '../Dialog' import * as components from './components' import * as styles from './styles' import type * as types from './types' -/** - * Maps the value to the event object. - */ -function mapValueOnEvent(value: unknown) { - if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) { - return value - } else { - return { target: { value } } - } -} - /** Form component. It wraps a `form` and provides form context. * It also handles form submission. * Provides better error handling and form state management and better UX out of the box. */ @@ -71,19 +59,12 @@ export const Form = forwardRef(function Form( formOptions.defaultValues = defaultValues } - const innerForm = components.useForm( - form ?? { - shouldFocusError: true, - schema, - ...formOptions, - }, - defaultValues, - ) - - const dialogContext = dialog.useDialogContext() + const innerForm = components.useForm(form ?? { shouldFocusError: true, schema, ...formOptions }) React.useImperativeHandle(formRef, () => innerForm, [innerForm]) + const dialogContext = dialog.useDialogContext() + const formMutation = reactQuery.useMutation({ // We use template literals to make the mutation key more readable in the devtools // This mutation exists only for debug purposes - React Query dev tools record the mutation, @@ -140,51 +121,13 @@ export const Form = forwardRef(function Form( { 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]) => { @@ -208,35 +151,34 @@ export const Form = 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' - > -> & - (( - props: React.RefAttributes & types.FormProps, - ) => React.JSX.Element) +}) as unknown as (( + props: React.RefAttributes & types.FormProps, +) => React.JSX.Element) & { + /* eslint-disable @typescript-eslint/naming-convention */ + schema: typeof components.schema + useForm: typeof components.useForm + useField: typeof components.useField + Submit: typeof components.Submit + Reset: typeof components.Reset + Field: typeof components.Field + FormError: typeof components.FormError + useFormSchema: typeof components.useFormSchema + Controller: typeof components.Controller + FIELD_STYLES: typeof components.FIELD_STYLES + /* eslint-enable @typescript-eslint/naming-convention */ +} Form.schema = components.schema Form.useForm = components.useForm @@ -246,4 +188,5 @@ Form.Submit = components.Submit Form.Reset = components.Reset Form.FormError = components.FormError Form.Field = components.Field +Form.Controller = components.Controller Form.FIELD_STYLES = components.FIELD_STYLES diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx index e841a76ad20e..37538602286d 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx @@ -23,7 +23,7 @@ export interface FieldComponentProps types.FieldProps { readonly 'data-testid'?: string | undefined readonly name: Path> - 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 9d60651847b5..2162e80e08f9 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/types.ts @@ -42,12 +42,41 @@ export interface UseFormProps readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema) } +/** + * Register function for a form field. + */ +export type UseFormRegister = < + TFieldName extends FieldPath = FieldPath, +>( + name: TFieldName, + options?: reactHookForm.RegisterOptions, TFieldName>, + // eslint-disable-next-line no-restricted-syntax +) => UseFormRegisterReturn + +/** + * UseFormRegister return type. + */ +export interface UseFormRegisterReturn< + Schema extends TSchema, + 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 */ export interface UseFormReturn - extends reactHookForm.UseFormReturn, unknown, TransformedValues> {} + extends reactHookForm.UseFormReturn, unknown, TransformedValues> { + 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 165bcc8e3820..f3bfb69d17c1 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts @@ -39,7 +39,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 ef0147673465..9d20e04645c0 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). @@ -28,9 +40,6 @@ import type * as types from './types' */ export function useForm( optionsOrFormInstance: types.UseFormProps | types.UseFormReturn, - defaultValues?: - | reactHookForm.DefaultValues> - | ((payload?: unknown) => Promise>), ): types.UseFormReturn { const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance)) @@ -44,38 +53,49 @@ export function useForm( `, ) - const form = - 'formState' in optionsOrFormInstance ? optionsOrFormInstance : ( - (() => { - const { schema, ...options } = optionsOrFormInstance - - const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema - - return reactHookForm.useForm< - types.FieldValues, - unknown, - types.TransformedValues - >({ - ...options, - resolver: zodResolver.zodResolver(computedSchema, { async: true }), - }) - })() - ) - - const initialDefaultValues = React.useRef(defaultValues) - - React.useEffect(() => { - // Expose default values to controlled inputs like `Selector` and `MultiSelector`. - // Using `defaultValues` is not sufficient as the value needs to be manually set at least once. - const defaults = initialDefaultValues.current - if (defaults) { - if (typeof defaults !== 'function') { - form.reset(defaults) + if ('formState' in optionsOrFormInstance) { + return optionsOrFormInstance + } else { + const { schema, ...options } = optionsOrFormInstance + + const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema + + const formInstance = reactHookForm.useForm< + types.FieldValues, + unknown, + types.TransformedValues + >({ + ...options, + 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 } - }, [form]) - return form + 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 0f27e4685a40..28fe075b093e 100644 --- a/app/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/types.ts @@ -42,11 +42,17 @@ interface BaseFormProps ) => unknown readonly style?: | React.CSSProperties - | ((props: FormStateRenderProps) => React.CSSProperties) - readonly children: React.ReactNode | ((props: FormStateRenderProps) => React.ReactNode) + | ((props: components.UseFormReturn) => React.CSSProperties) + readonly children: + | React.ReactNode + | (( + props: components.UseFormReturn & { + readonly form: components.UseFormReturn + }, + ) => React.ReactNode) readonly formRef?: React.MutableRefObject> - readonly className?: string | ((props: FormStateRenderProps) => string) + readonly className?: 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..07827c44d07d --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx @@ -0,0 +1,140 @@ +/** + * @file + * + * A switch allows a user to turn a setting on or off. + */ +import { + Switch as AriaSwitch, + mergeProps, + type SwitchProps as AriaSwitchProps, +} from '#/components/aria' +import { mergeRefs } from '#/utilities/mergeRefs' +import { forwardRef } from '#/utilities/react' +import type { CSSProperties, ForwardedRef } from 'react' +import { useRef } from 'react' +import { tv, type VariantProps } from 'tailwind-variants' +import { Form, type FieldPath, type FieldProps, type FieldStateProps, type TSchema } from '../Form' +import { TEXT_STYLE } from '../Text' + +/** + * Props for the {@Switch} component. + */ +export interface SwitchProps> + extends FieldStateProps< + Omit & { value: boolean }, + Schema, + TFieldName + >, + 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, + TFieldName extends FieldPath, +>(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}
+
+
+ ) +}) 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 0f89a8aa6237..3b1e74fdb1e2 100644 --- a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx +++ b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx @@ -17,17 +17,19 @@ import * as billing from '#/hooks/billing' import * as authProvider from '#/providers/AuthProvider' import { UserSessionType } from '#/providers/AuthProvider' +import * as textProvider from '#/providers/TextProvider' import { useEnableVersionChecker, + usePaywallDevtools, useSetEnableVersionChecker, -} from '#/providers/EnsoDevtoolsProvider' -import * as textProvider from '#/providers/TextProvider' +} from './EnsoDevtoolsProvider' + +import * as ariaComponents from '#/components/AriaComponents' +import Portal from '#/components/Portal' -import { Switch } from '#/components/aria' import { Button, ButtonGroup, - DialogTrigger, Form, Popover, Radio, @@ -35,246 +37,249 @@ import { Separator, Text, } from '#/components/AriaComponents' -import Portal from '#/components/Portal' - +import { + FEATURE_FLAGS_SCHEMA, + useFeatureFlags, + useSetFeatureFlags, +} from '#/providers/FeatureFlagsProvider' import { useLocalStorage } from '#/providers/LocalStorageProvider' import * as backend from '#/services/Backend' import LocalStorage from '#/utilities/LocalStorage' import { unsafeEntries } from 'enso-common/src/utilities/data/object' -/** - * Configuration for a paywall feature. - */ -export interface PaywallDevtoolsFeatureConfiguration { - readonly isForceEnabled: boolean | null -} - -const PaywallDevtoolsContext = React.createContext<{ - features: Record -}>({ - features: { - share: { isForceEnabled: null }, - shareFull: { isForceEnabled: null }, - userGroups: { isForceEnabled: null }, - userGroupsFull: { isForceEnabled: null }, - inviteUser: { isForceEnabled: null }, - inviteUserFull: { isForceEnabled: null }, - }, -}) - -/** - * Props for the {@link EnsoDevtools} component. - */ -interface EnsoDevtoolsProps extends React.PropsWithChildren {} - /** * A component that provides a UI for toggling paywall features. */ -export function EnsoDevtools(props: EnsoDevtoolsProps) { - const { children } = props - +export function EnsoDevtools() { const { getText } = textProvider.useText() const { authQueryKey, session } = authProvider.useAuth() + const queryClient = reactQuery.useQueryClient() + const { getFeature } = billing.usePaywallFeatures() + const { features, setFeature } = usePaywallDevtools() const enableVersionChecker = useEnableVersionChecker() const setEnableVersionChecker = useSetEnableVersionChecker() const { localStorage } = useLocalStorage() - const [features, setFeatures] = React.useState< - Record - >({ - 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} - - - - + + )} + + + + + {/* eslint-disable-next-line no-restricted-syntax */} + + + + + )} + + + {getText('productionOnlyFeatures')} + + + z.object({ enableVersionChecker: z.boolean() })} + defaultValues={{ enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE }} + > + {({ form }) => ( + { + setEnableVersionChecker(value) + }} + /> + )} + + + + + + {getText('localStorage')} + + + {unsafeEntries(LocalStorage.keyMetadata).map(([key]) => ( +
+ + - - )} - - - - - {/* eslint-disable-next-line no-restricted-syntax */} - - - - - )} - - - {getText('productionOnlyFeatures')} - -
- -
- -
- - {getText('enableVersionChecker')} -
- - - {getText('enableVersionCheckerDescription')} + + + {key + .replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase()) + .replace(/^./, (m) => m.toUpperCase())}
- - - - - {getText('localStorage')} - - {unsafeEntries(LocalStorage.keyMetadata).map(([key]) => ( -
- - - - - {key - .replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase()) - .replace(/^./, (m) => m.toUpperCase())} - -
- ))} - - - - - {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 ( -
- + +
+ { - onConfigurationChange(featureName, { - isForceEnabled: value, - }) + setFeatureFlags('enableAssetsTableBackgroundRefresh', value) }} - > -
- -
- - {getText(label)} - - - - {getText(descriptionTextId)} - + /> + { + 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 c014c70a2e32..014eb70ea8cb 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -1,64 +1,58 @@ /** @file A table row for an arbitrary asset. */ import * as React from 'react' -import { useMutation } from '@tanstack/react-query' import { useStore } from 'zustand' import BlankIcon from '#/assets/blank.svg' -import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks' import * as dragAndDropHooks from '#/hooks/dragAndDropHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as setAssetHooks from '#/hooks/setAssetHooks' -import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' -import * as authProvider from '#/providers/AuthProvider' import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider' import * as modalProvider from '#/providers/ModalProvider' import * as textProvider from '#/providers/TextProvider' -import AssetEventType from '#/events/AssetEventType' -import AssetListEventType from '#/events/AssetListEventType' - -import AssetContextMenu from '#/layouts/AssetContextMenu' -import type * as assetsTable from '#/layouts/AssetsTable' -import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' - import * as aria from '#/components/aria' import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils' import * as columnModule from '#/components/dashboard/column' import * as columnUtils from '#/components/dashboard/column/columnUtils' import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner' import FocusRing from '#/components/styled/FocusRing' +import AssetEventType from '#/events/AssetEventType' +import AssetListEventType from '#/events/AssetListEventType' +import AssetContextMenu from '#/layouts/AssetContextMenu' +import type * as assetsTable from '#/layouts/AssetsTable' +import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' +import { isCloudCategory } from '#/layouts/CategorySwitcher/Category' +import * as localBackend from '#/services/LocalBackend' import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal' import * as backendModule from '#/services/Backend' -import * as localBackend from '#/services/LocalBackend' +import { backendMutationOptions } from '#/hooks/backendHooks' import { createGetProjectDetailsQuery } from '#/hooks/projectHooks' -import { isCloudCategory } from '#/layouts/CategorySwitcher/Category' +import { useToastAndLog } from '#/hooks/toastAndLogHooks' +import { useFullUserSession } from '#/providers/AuthProvider' import type * as assetTreeNode from '#/utilities/AssetTreeNode' -import * as dateTime from '#/utilities/dateTime' -import * as download from '#/utilities/download' +import { download } from '#/utilities/download' import * as drag from '#/utilities/drag' import * as eventModule from '#/utilities/event' -import * as fileInfo from '#/utilities/fileInfo' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' -import * as path from '#/utilities/path' import * as permissions from '#/utilities/permissions' import * as set from '#/utilities/set' import * as tailwindMerge from '#/utilities/tailwindMerge' import Visibility from '#/utilities/Visibility' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' // ================= // === Constants === // ================= /** The height of the header row. */ -const HEADER_HEIGHT_PX = 34 +const HEADER_HEIGHT_PX = 40 /** The amount of time (in milliseconds) the drag item must be held over this component * to make a directory row expand. */ const DRAG_EXPAND_DELAY_MS = 500 @@ -96,12 +90,24 @@ export interface AssetRowProps export default function AssetRow(props: AssetRowProps) { const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props - const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state + const { + nodeMap, + setAssetPanelProps, + doToggleDirectoryExpansion, + doCopy, + doCut, + doPaste, + doDelete: doDeleteRaw, + doRestore, + doMove, + category, + } = state const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state - const { visibilities, category } = state + const { visibilities } = state const [item, setItem] = React.useState(rawItem) const driveStore = useDriveStore() + const { user } = useFullUserSession() const setSelectedKeys = useSetSelectedKeys() const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) => (visuallySelectedKeys ?? selectedKeys).has(item.key), @@ -115,14 +121,10 @@ export default function AssetRow(props: AssetRowProps) { ({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected, ) const draggableProps = dragAndDropHooks.useDraggable() - const { user } = authProvider.useFullUserSession() const { setModal, unsetModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() - const toastAndLog = toastAndLogHooks.useToastAndLog() const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() - const { data: users } = useBackendQuery(backend, 'listUsers', []) - const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', []) const [isDraggedOver, setIsDraggedOver] = React.useState(false) const rootRef = React.useRef(null) const dragOverTimeoutHandle = React.useRef(null) @@ -137,33 +139,14 @@ export default function AssetRow(props: AssetRowProps) { readonly nodeMap: WeakRef> readonly parentKeys: Map } | null>(null) - const isCloud = isCloudCategory(category) + const outerVisibility = visibilities.get(item.key) const visibility = outerVisibility == null || outerVisibility === Visibility.visible ? insertionVisibility : outerVisibility const hidden = hiddenRaw || visibility === Visibility.hidden - - const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset')) - const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset')) - const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset')) - const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset')) - const openProjectMutation = useMutation(backendMutationOptions(backend, 'openProject')) - const closeProjectMutation = useMutation(backendMutationOptions(backend, 'closeProject')) - const getProjectDetailsMutation = useMutation( - backendMutationOptions(backend, 'getProjectDetails'), - ) - const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails')) - const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink')) - const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission')) - const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) - const copyAsset = copyAssetMutation.mutateAsync - const updateAsset = updateAssetMutation.mutateAsync - const deleteAsset = deleteAssetMutation.mutateAsync - const undoDeleteAsset = undoDeleteAssetMutation.mutateAsync - const openProject = openProjectMutation.mutateAsync - const closeProject = closeProjectMutation.mutateAsync + const isCloud = isCloudCategory(category) const { data: projectState } = useQuery({ // This is SAFE, as `isOpened` is only true for projects. @@ -173,6 +156,16 @@ export default function AssetRow(props: AssetRowProps) { enabled: item.type === backendModule.AssetType.project, }) + const toastAndLog = useToastAndLog() + + const getProjectDetailsMutation = useMutation( + backendMutationOptions(backend, 'getProjectDetails'), + ) + const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails')) + const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink')) + const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission')) + const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) + const setSelected = useEventCallback((newSelected: boolean) => { const { selectedKeys } = driveStore.getState() setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected)) @@ -204,155 +197,6 @@ export default function AssetRow(props: AssetRowProps) { React.useImperativeHandle(updateAssetRef, () => setAsset) - const doCopyOnBackend = React.useCallback( - async (newParentId: backendModule.DirectoryId | null) => { - try { - setAsset((oldAsset) => - object.merge(oldAsset, { - title: oldAsset.title + ' (copy)', - labels: [], - permissions: permissions.tryCreateOwnerPermission( - `${item.path} (copy)`, - category, - user, - users ?? [], - userGroups ?? [], - ), - modifiedAt: dateTime.toRfc3339(new Date()), - }), - ) - newParentId ??= rootDirectoryId - const copiedAsset = await copyAsset([ - asset.id, - newParentId, - asset.title, - nodeMap.current.get(newParentId)?.item.title ?? '(unknown)', - ]) - setAsset( - // This is SAFE, as the type of the copied asset is guaranteed to be the same - // as the type of the original asset. - // eslint-disable-next-line no-restricted-syntax - object.merger({ - ...copiedAsset.asset, - state: { type: backendModule.ProjectState.new }, - } as Partial), - ) - } catch (error) { - toastAndLog('copyAssetError', error, asset.title) - // Delete the new component representing the asset that failed to insert. - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - } - }, - [ - setAsset, - rootDirectoryId, - copyAsset, - asset.id, - asset.title, - nodeMap, - item.path, - item.key, - category, - user, - users, - userGroups, - toastAndLog, - dispatchAssetListEvent, - ], - ) - - const doMove = React.useCallback( - async ( - newParentKey: backendModule.DirectoryId | null, - newParentId: backendModule.DirectoryId | null, - ) => { - const nonNullNewParentKey = newParentKey ?? rootDirectoryId - const nonNullNewParentId = newParentId ?? rootDirectoryId - try { - setItem((oldItem) => - oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId }), - ) - const newParentPath = localBackend.extractTypeAndId(nonNullNewParentId).id - let newId = asset.id - if (!isCloud) { - const oldPath = localBackend.extractTypeAndId(asset.id).id - const newPath = path.joinPath(newParentPath, fileInfo.getFileName(oldPath)) - switch (asset.type) { - case backendModule.AssetType.file: { - newId = localBackend.newFileId(newPath) - break - } - case backendModule.AssetType.directory: { - newId = localBackend.newDirectoryId(newPath) - break - } - case backendModule.AssetType.project: - case backendModule.AssetType.secret: - case backendModule.AssetType.datalink: - case backendModule.AssetType.specialLoading: - case backendModule.AssetType.specialEmpty: { - // Ignored. - // Project paths are not stored in their `id`; - // The other asset types either do not exist on the Local backend, - // or do not have a path. - break - } - } - } - // This is SAFE as the type of `newId` is not changed from its original type. - // eslint-disable-next-line no-restricted-syntax - const newAsset = object.merge(asset, { id: newId as never, parentId: nonNullNewParentId }) - dispatchAssetListEvent({ - type: AssetListEventType.move, - newParentKey: nonNullNewParentKey, - newParentId: nonNullNewParentId, - key: item.key, - item: newAsset, - }) - setAsset(newAsset) - await updateAsset([ - asset.id, - { parentDirectoryId: newParentId ?? rootDirectoryId, description: null }, - asset.title, - ]) - } catch (error) { - toastAndLog('moveAssetError', error, asset.title) - setAsset( - object.merger({ - // This is SAFE as the type of `newId` is not changed from its original type. - // eslint-disable-next-line no-restricted-syntax - id: asset.id as never, - parentId: asset.parentId, - projectState: asset.projectState, - }), - ) - setItem((oldItem) => - oldItem.with({ directoryKey: item.directoryKey, directoryId: item.directoryId }), - ) - // Move the asset back to its original position. - dispatchAssetListEvent({ - type: AssetListEventType.move, - newParentKey: item.directoryKey, - newParentId: item.directoryId, - key: item.key, - item: asset, - }) - } - }, - [ - isCloud, - asset, - rootDirectoryId, - item.directoryId, - item.directoryKey, - item.key, - toastAndLog, - updateAsset, - setAsset, - dispatchAssetListEvent, - ], - ) - React.useEffect(() => { if (isSoleSelected) { setAssetPanelProps({ backend, item, setItem }) @@ -361,66 +205,12 @@ export default function AssetRow(props: AssetRowProps) { }, [item, isSoleSelected, backend, setAssetPanelProps, setIsAssetPanelTemporarilyVisible]) const doDelete = React.useCallback( - async (forever = false) => { - setInsertionVisibility(Visibility.hidden) - if (asset.type === backendModule.AssetType.directory) { - dispatchAssetListEvent({ - type: AssetListEventType.closeFolder, - id: asset.id, - // This is SAFE, as this asset is already known to be a directory. - // eslint-disable-next-line no-restricted-syntax - key: item.key as backendModule.DirectoryId, - }) - } - try { - dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: item.key }) - if ( - asset.type === backendModule.AssetType.project && - backend.type === backendModule.BackendType.local - ) { - if ( - asset.projectState.type !== backendModule.ProjectState.placeholder && - asset.projectState.type !== backendModule.ProjectState.closed - ) { - await openProject([asset.id, null, asset.title]) - } - try { - await closeProject([asset.id, asset.title]) - } catch { - // Ignored. The project was already closed. - } - } - await deleteAsset([asset.id, { force: forever }, asset.title]) - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - } catch (error) { - setInsertionVisibility(Visibility.visible) - toastAndLog('deleteAssetError', error, asset.title) - } + (forever = false) => { + void doDeleteRaw(item.item, forever) }, - [ - backend, - dispatchAssetListEvent, - asset, - openProject, - closeProject, - deleteAsset, - item.key, - toastAndLog, - ], + [doDeleteRaw, item.item], ) - const doRestore = React.useCallback(async () => { - // Visually, the asset is deleted from the Trash view. - setInsertionVisibility(Visibility.hidden) - try { - await undoDeleteAsset([asset.id, asset.title]) - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - } catch (error) { - setInsertionVisibility(Visibility.visible) - toastAndLog('restoreAssetError', error, asset.title) - } - }, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAsset, item.key]) - const doTriggerDescriptionEdit = React.useCallback(() => { setModal( { + setIsDraggedOver(false) + setRowState((oldRowState) => + oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? + oldRowState + : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), + ) + }, []) + + const onDragOver = (event: React.DragEvent) => { + const directoryKey = + item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey + const payload = drag.ASSET_ROWS.lookup(event) + const isPayloadMatch = + payload != null && payload.every((innerItem) => innerItem.key !== directoryKey) + const canPaste = (() => { + if (!isPayloadMatch) { + return true + } else { + if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) { + const parentKeys = new Map( + Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [ + id, + otherAsset.directoryKey, + ]), + ) + nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys } + } + return !payload.some((payloadItem) => { + const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key) + const parent = parentKey == null ? null : nodeMap.current.get(parentKey) + return !parent ? true : ( + permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path) + ) + }) + } + })() + if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) { + event.preventDefault() + if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') { + setIsDraggedOver(true) + } + } + } + eventListProvider.useAssetEventListener(async (event) => { if (state.category.type === 'trash') { switch (event.type) { case AssetEventType.deleteForever: { if (event.ids.has(item.key)) { - await doDelete(true) + doDelete(true) } break } case AssetEventType.restore: { if (event.ids.has(item.key)) { - await doRestore() + await doRestore(item.item) } break } @@ -462,22 +297,6 @@ export default function AssetRow(props: AssetRowProps) { } } else { switch (event.type) { - // These events are handled in the specific `NameColumn` files. - case AssetEventType.newProject: - case AssetEventType.newFolder: - case AssetEventType.uploadFiles: - case AssetEventType.newDatalink: - case AssetEventType.newSecret: - case AssetEventType.updateFiles: - case AssetEventType.projectClosed: { - break - } - case AssetEventType.copy: { - if (event.ids.has(item.key)) { - await doCopyOnBackend(event.newParentId) - } - break - } case AssetEventType.cut: { if (event.ids.has(item.key)) { setInsertionVisibility(Visibility.faded) @@ -493,25 +312,25 @@ export default function AssetRow(props: AssetRowProps) { case AssetEventType.move: { if (event.ids.has(item.key)) { setInsertionVisibility(Visibility.visible) - await doMove(event.newParentKey, event.newParentId) + await doMove(event.newParentKey, item.item) } break } case AssetEventType.delete: { if (event.ids.has(item.key)) { - await doDelete(false) + doDelete(false) } break } case AssetEventType.deleteForever: { if (event.ids.has(item.key)) { - await doDelete(true) + doDelete(true) } break } case AssetEventType.restore: { if (event.ids.has(item.key)) { - await doRestore() + await doRestore(item.item) } break } @@ -559,7 +378,7 @@ export default function AssetRow(props: AssetRowProps) { try { const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title]) const fileName = `${asset.title}.datalink` - download.download( + download( URL.createObjectURL( new File([JSON.stringify(value)], fileName, { type: 'application/json+x-enso-data-link', @@ -712,54 +531,12 @@ export default function AssetRow(props: AssetRowProps) { } break } - } - } - }, item.initialAssetEvents) - - const clearDragState = React.useCallback(() => { - setIsDraggedOver(false) - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - }, []) - - const onDragOver = (event: React.DragEvent) => { - const directoryKey = - item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey - const payload = drag.ASSET_ROWS.lookup(event) - const isPayloadMatch = - payload != null && payload.every((innerItem) => innerItem.key !== directoryKey) - const canPaste = (() => { - if (!isPayloadMatch) { - return true - } else { - if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) { - const parentKeys = new Map( - Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [ - id, - otherAsset.directoryKey, - ]), - ) - nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys } + default: { + return } - return !payload.some((payloadItem) => { - const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key) - const parent = parentKey == null ? null : nodeMap.current.get(parentKey) - return !parent ? true : ( - permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path) - ) - }) - } - })() - if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) { - event.preventDefault() - if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') { - setIsDraggedOver(true) } } - } + }, item.initialAssetEvents) switch (asset.type) { case backendModule.AssetType.directory: @@ -784,18 +561,23 @@ export default function AssetRow(props: AssetRowProps) { tabIndex={0} ref={(element) => { rootRef.current = element - if (isSoleSelected && element != null && scrollContainerRef.current != null) { - const rect = element.getBoundingClientRect() - const scrollRect = scrollContainerRef.current.getBoundingClientRect() - const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX) - const scrollDown = rect.bottom - scrollRect.bottom - if (scrollUp < 0 || scrollDown > 0) { - scrollContainerRef.current.scrollBy({ - top: scrollUp < 0 ? scrollUp : scrollDown, - behavior: 'smooth', - }) + + requestAnimationFrame(() => { + if (isSoleSelected && element != null && scrollContainerRef.current != null) { + const rect = element.getBoundingClientRect() + const scrollRect = scrollContainerRef.current.getBoundingClientRect() + const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX) + const scrollDown = rect.bottom - scrollRect.bottom + + if (scrollUp < 0 || scrollDown > 0) { + scrollContainerRef.current.scrollBy({ + top: scrollUp < 0 ? scrollUp : scrollDown, + behavior: 'smooth', + }) + } } - } + }) + if (isKeyboardSelected && element?.contains(document.activeElement) === false) { element.focus() } @@ -819,7 +601,7 @@ export default function AssetRow(props: AssetRowProps) { window.setTimeout(() => { setSelected(false) }) - doToggleDirectoryExpansion(item.item.id, item.key, asset.title) + doToggleDirectoryExpansion(item.item.id, item.key) } }} onContextMenu={(event) => { @@ -864,7 +646,7 @@ export default function AssetRow(props: AssetRowProps) { } if (item.type === backendModule.AssetType.directory) { dragOverTimeoutHandle.current = window.setTimeout(() => { - doToggleDirectoryExpansion(item.item.id, item.key, asset.title, true) + doToggleDirectoryExpansion(item.item.id, item.key, true) }, DRAG_EXPAND_DELAY_MS) } // Required because `dragover` does not fire on `mouseenter`. @@ -902,10 +684,10 @@ export default function AssetRow(props: AssetRowProps) { if (state.category.type !== 'trash') { props.onDrop?.(event) clearDragState() - const [directoryKey, directoryId, directoryTitle] = + const [directoryKey, directoryId] = item.type === backendModule.AssetType.directory ? - [item.key, item.item.id, asset.title] - : [item.directoryKey, item.directoryId, null] + [item.key, item.item.id] + : [item.directoryKey, item.directoryId] const payload = drag.ASSET_ROWS.lookup(event) if ( payload != null && @@ -914,7 +696,7 @@ export default function AssetRow(props: AssetRowProps) { event.preventDefault() event.stopPropagation() unsetModal() - doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true) + doToggleDirectoryExpansion(directoryId, directoryKey, true) const ids = payload .filter((payloadItem) => payloadItem.asset.parentId !== directoryId) .map((dragItem) => dragItem.key) @@ -927,7 +709,7 @@ export default function AssetRow(props: AssetRowProps) { } else if (event.dataTransfer.types.includes('Files')) { event.preventDefault() event.stopPropagation() - doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true) + doToggleDirectoryExpansion(directoryId, directoryKey, true) dispatchAssetListEvent({ type: AssetListEventType.uploadFiles, parentKey: directoryKey, 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 dd4c8b1ba674..f5a6ad08e1b7 100644 --- a/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx @@ -12,11 +12,6 @@ import { useDriveStore } from '#/providers/DriveProvider' import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as textProvider from '#/providers/TextProvider' -import AssetEventType from '#/events/AssetEventType' -import AssetListEventType from '#/events/AssetListEventType' - -import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' - import * as ariaComponents from '#/components/AriaComponents' import type * as column from '#/components/dashboard/column' import EditableSpan from '#/components/EditableSpan' @@ -30,7 +25,6 @@ import * as object from '#/utilities/object' import * as string from '#/utilities/string' import * as tailwindMerge from '#/utilities/tailwindMerge' import * as validation from '#/utilities/validation' -import Visibility from '#/utilities/Visibility' // ===================== // === DirectoryName === @@ -45,21 +39,22 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {} export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const { item, setItem, selected, state, rowState, setRowState, isEditable } = props const { backend, nodeMap } = state - const { doToggleDirectoryExpansion } = state + const { doToggleDirectoryExpansion, expandedDirectoryIds } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() const driveStore = useDriveStore() - const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() + if (item.type !== backendModule.AssetType.directory) { // eslint-disable-next-line no-restricted-syntax throw new Error('`DirectoryNameColumn` can only display folders.') } + const asset = item.item const setAsset = setAssetHooks.useSetAsset(asset, setItem) - const isExpanded = item.children != null && item.isExpanded - const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory')) + const isExpanded = item.children != null && expandedDirectoryIds.includes(asset.id) + const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory')) const setIsEditing = (isEditingName: boolean) => { @@ -91,59 +86,6 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { } } - eventListProvider.useAssetEventListener(async (event) => { - if (isEditable) { - switch (event.type) { - case AssetEventType.newProject: - case AssetEventType.uploadFiles: - case AssetEventType.newDatalink: - case AssetEventType.newSecret: - case AssetEventType.updateFiles: - case AssetEventType.copy: - case AssetEventType.cut: - case AssetEventType.cancelCut: - case AssetEventType.move: - case AssetEventType.delete: - case AssetEventType.deleteForever: - case AssetEventType.restore: - case AssetEventType.download: - case AssetEventType.downloadSelected: - case AssetEventType.removeSelf: - case AssetEventType.temporarilyAddLabels: - case AssetEventType.temporarilyRemoveLabels: - case AssetEventType.addLabels: - case AssetEventType.removeLabels: - case AssetEventType.deleteLabel: - case AssetEventType.setItem: - case AssetEventType.projectClosed: { - // Ignored. These events should all be unrelated to directories. - // `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected` - // are handled by`AssetRow`. - break - } - case AssetEventType.newFolder: { - if (item.key === event.placeholderId) { - rowState.setVisibility(Visibility.faded) - try { - const createdDirectory = await createDirectoryMutation.mutateAsync([ - { - parentId: asset.parentId, - title: asset.title, - }, - ]) - rowState.setVisibility(Visibility.visible) - setAsset(object.merge(asset, createdDirectory)) - } catch (error) { - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - toastAndLog('createFolderError', error) - } - } - break - } - } - } - }, item.initialAssetEvents) - const handleClick = inputBindings.handler({ editName: () => { setIsEditing(true) @@ -185,7 +127,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { isExpanded && 'rotate-90', )} onPress={() => { - doToggleDirectoryExpansion(asset.id, item.key, asset.title) + doToggleDirectoryExpansion(asset.id, item.key) }} /> @@ -193,7 +135,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { data-testid="asset-row-name" editable={rowState.isEditingName} className={tailwindMerge.twMerge( - 'text grow cursor-pointer bg-transparent font-naming', + 'grow cursor-pointer bg-transparent font-naming', rowState.isEditingName ? 'cursor-text' : 'cursor-pointer', )} checkSubmittable={(newTitle) => 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 676fddb56574..6e4dc22483b2 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' @@ -15,19 +15,12 @@ import { useDriveStore } from '#/providers/DriveProvider' import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as textProvider from '#/providers/TextProvider' -import AssetEventType from '#/events/AssetEventType' -import AssetListEventType from '#/events/AssetListEventType' - -import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' - import type * as column from '#/components/dashboard/column' import ProjectIcon from '#/components/dashboard/ProjectIcon' import EditableSpan from '#/components/EditableSpan' import SvgMask from '#/components/SvgMask' import * as backendModule from '#/services/Backend' -import * as localBackend from '#/services/LocalBackend' -import * as projectManager from '#/services/ProjectManager' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' @@ -36,7 +29,6 @@ import * as permissions from '#/utilities/permissions' import * as string from '#/utilities/string' import * as tailwindMerge from '#/utilities/tailwindMerge' import * as validation from '#/utilities/validation' -import Visibility from '#/utilities/Visibility' // =================== // === ProjectName === @@ -61,12 +53,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { isOpened, } = props const { backend, nodeMap } = state - const client = useQueryClient() + const toastAndLog = toastAndLogHooks.useToastAndLog() const { user } = authProvider.useFullUserSession() const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() - const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const driveStore = useDriveStore() const doOpenProject = projectHooks.useOpenProject() @@ -74,6 +65,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { // eslint-disable-next-line no-restricted-syntax throw new Error('`ProjectNameColumn` can only display projects.') } + const asset = item.item const setAsset = setAssetHooks.useSetAsset(asset, setItem) const ownPermission = @@ -96,20 +88,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { const isOtherUserUsingProject = isCloud && projectState.openedBy != null && projectState.openedBy !== user.email - const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject')) const updateProjectMutation = useMutation(backendMutationOptions(backend, 'updateProject')) - const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject')) - const getProjectDetailsMutation = useMutation( - backendMutationOptions(backend, 'getProjectDetails'), - ) - const uploadFileMutation = useMutation( - backendMutationOptions(backend, 'uploadFile', { - meta: { - invalidates: [['assetVersions', item.item.id, item.item.title]], - awaitInvalidates: true, - }, - }), - ) const setIsEditing = (isEditingName: boolean) => { if (isEditable) { @@ -131,9 +110,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { { ami: null, ideVersion: null, projectName: newTitle }, asset.title, ]) - await client.invalidateQueries({ - queryKey: projectHooks.createGetProjectDetailsQuery.getQueryKey(asset.id), - }) } catch (error) { toastAndLog('renameProjectError', error) setAsset(object.merger({ title: oldTitle })) @@ -141,166 +117,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { } } - eventListProvider.useAssetEventListener(async (event) => { - if (isEditable) { - switch (event.type) { - case AssetEventType.newFolder: - case AssetEventType.newDatalink: - case AssetEventType.newSecret: - case AssetEventType.copy: - case AssetEventType.cut: - case AssetEventType.cancelCut: - case AssetEventType.move: - case AssetEventType.delete: - case AssetEventType.deleteForever: - case AssetEventType.restore: - case AssetEventType.download: - case AssetEventType.downloadSelected: - case AssetEventType.removeSelf: - case AssetEventType.temporarilyAddLabels: - case AssetEventType.temporarilyRemoveLabels: - case AssetEventType.addLabels: - case AssetEventType.removeLabels: - case AssetEventType.deleteLabel: - case AssetEventType.setItem: - case AssetEventType.projectClosed: { - // Ignored. Any missing project-related events should be handled by `ProjectIcon`. - // `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected` - // are handled by`AssetRow`. - break - } - case AssetEventType.newProject: { - // This should only run before this project gets replaced with the actual project - // by this event handler. In both cases `key` will match, so using `key` here - // is a mistake. - if (asset.id === event.placeholderId) { - rowState.setVisibility(Visibility.faded) - try { - const createdProject = - event.originalId == null || event.versionId == null ? - await createProjectMutation.mutateAsync([ - { - parentDirectoryId: asset.parentId, - projectName: asset.title, - ...(event.templateId == null ? - {} - : { projectTemplateName: event.templateId }), - ...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }), - }, - ]) - : await duplicateProjectMutation.mutateAsync([ - event.originalId, - event.versionId, - asset.title, - ]) - event.onCreated?.(createdProject) - rowState.setVisibility(Visibility.visible) - setAsset( - object.merge(asset, { - id: createdProject.projectId, - projectState: object.merge(projectState, { - type: backendModule.ProjectState.placeholder, - }), - }), - ) - doOpenProject({ - id: createdProject.projectId, - type: backendType, - parentId: asset.parentId, - title: asset.title, - }) - } catch (error) { - event.onError?.() - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - toastAndLog('createProjectError', error) - } - } - break - } - case AssetEventType.updateFiles: - case AssetEventType.uploadFiles: { - const file = event.files.get(item.key) - if (file != null) { - const fileId = event.type !== AssetEventType.updateFiles ? null : asset.id - rowState.setVisibility(Visibility.faded) - const { extension } = backendModule.extractProjectExtension(file.name) - const title = backendModule.stripProjectExtension(asset.title) - setAsset(object.merge(asset, { title })) - try { - if (backend.type === backendModule.BackendType.local) { - const directory = localBackend.extractTypeAndId(item.directoryId).id - let id: string - if ( - 'backendApi' in window && - // This non-standard property is defined in Electron. - 'path' in file && - typeof file.path === 'string' - ) { - id = await window.backendApi.importProjectFromPath(file.path, directory, title) - } else { - const searchParams = new URLSearchParams({ directory, name: title }).toString() - // Ideally this would use `file.stream()`, to minimize RAM - // requirements. for uploading large projects. Unfortunately, - // this requires HTTP/2, which is HTTPS-only, so it will not - // work on `http://localhost`. - const body = - window.location.protocol === 'https:' ? file.stream() : await file.arrayBuffer() - const path = `./api/upload-project?${searchParams}` - const response = await fetch(path, { method: 'POST', body }) - id = await response.text() - } - const projectId = localBackend.newProjectId(projectManager.UUID(id)) - const listedProject = await getProjectDetailsMutation.mutateAsync([ - projectId, - asset.parentId, - file.name, - ]) - rowState.setVisibility(Visibility.visible) - setAsset(object.merge(asset, { title: listedProject.packageName, id: projectId })) - } else { - const createdFile = await uploadFileMutation.mutateAsync([ - { - fileId, - fileName: `${title}.${extension}`, - parentDirectoryId: asset.parentId, - }, - file, - ]) - const project = createdFile.project - if (project == null) { - throw new Error('The uploaded file was not a project.') - } else { - rowState.setVisibility(Visibility.visible) - setAsset( - object.merge(asset, { - title, - id: project.projectId, - projectState: project.state, - }), - ) - return - } - } - } catch (error) { - switch (event.type) { - case AssetEventType.uploadFiles: { - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - toastAndLog('uploadProjectError', error) - break - } - case AssetEventType.updateFiles: { - toastAndLog('updateProjectError', error) - break - } - } - } - } - break - } - } - } - }, item.initialAssetEvents) - const handleClick = inputBindings.handler({ editName: () => { setIsEditing(true) @@ -354,7 +170,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { data-testid="asset-row-name" editable={rowState.isEditingName} className={tailwindMerge.twMerge( - 'text grow bg-transparent font-naming', + 'grow bg-transparent font-naming', canExecute && !isOtherUserUsingProject && 'cursor-pointer', rowState.isEditingName && 'cursor-text', )} 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/components/dashboard/column/SharedWithColumn.tsx b/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index e8917dbe8112..ccf6a4b2af0a 100644 --- a/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -70,9 +70,9 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { return (
- {(asset.permissions ?? []).map((other) => ( + {(asset.permissions ?? []).map((other, idx) => ( readonly key: backend.AssetId readonly newParentKey: backend.DirectoryId readonly newParentId: backend.DirectoryId - readonly item: backend.AnyAsset + readonly items: backend.AnyAsset[] } /** A signal that a file has been deleted. */ 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 ae2d18bb019b..4f026f1fa71a 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' @@ -231,11 +232,17 @@ export function useOpenProject() { const addLaunchedProject = useAddLaunchedProject() const closeAllProjects = useCloseAllProjects() const openProjectMutation = useOpenProjectMutation() + + const enableMultitabs = useFeatureFlag('enableMultitabs') + return eventCallbacks.useEventCallback((project: LaunchedProject) => { - // Since multiple tabs cannot be opened at the sametime, the opened projects need to be closed first. - if (projectsStore.getState().launchedProjects.length > 0) { - closeAllProjects() + if (!enableMultitabs) { + // Since multiple tabs cannot be opened at the same time, the opened projects need to be closed first. + if (projectsStore.getState().launchedProjects.length > 0) { + closeAllProjects() + } } + const existingMutation = client.getMutationCache().find({ mutationKey: ['openProject'], predicate: (mutation) => mutation.options.scope?.id === project.id, diff --git a/app/dashboard/src/hooks/syncRefHooks.ts b/app/dashboard/src/hooks/syncRefHooks.ts index 9472484c0e90..21f3f13204d5 100644 --- a/app/dashboard/src/hooks/syncRefHooks.ts +++ b/app/dashboard/src/hooks/syncRefHooks.ts @@ -12,11 +12,12 @@ 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. - React.useEffect(() => { - ref.current = value - }) + /* + Even though the react core team doesn't recommend setting ref values during the render (it might lead to deoptimizations), the reasoning behind this is: + - We want to make useEventCallback behave the same way as const x = () => {} or useCallback but have a stable reference. + - React components shall be idempotent by default, and we don't see violations here. + */ + ref.current = value return ref } diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index 3547de7e75a3..6cc45a0add4f 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 { useMutation, 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' @@ -12,7 +18,6 @@ import * as mimeTypes from '#/data/mimeTypes' import * as autoScrollHooks from '#/hooks/autoScrollHooks' import { backendMutationOptions, useBackendQuery } 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' @@ -34,7 +39,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' @@ -66,11 +70,13 @@ 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 { 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' import AssetQuery from '#/utilities/AssetQuery' import type * as assetTreeNode from '#/utilities/AssetTreeNode' @@ -199,96 +205,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 === // ========================= @@ -323,6 +239,7 @@ const CATEGORY_TO_FILTER_BY: Readonly readonly visibilities: ReadonlyMap readonly category: Category @@ -344,7 +261,6 @@ export interface AssetsTableState { readonly doToggleDirectoryExpansion: ( directoryId: backendModule.DirectoryId, key: backendModule.DirectoryId, - title?: string | null, override?: boolean, ) => void readonly doCopy: () => void @@ -353,6 +269,12 @@ export interface AssetsTableState { newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId, ) => void + readonly doDelete: (item: backendModule.AnyAsset, forever: boolean) => Promise + readonly doRestore: (item: backendModule.AnyAsset) => Promise + readonly doMove: ( + newParentKey: backendModule.DirectoryId, + item: backendModule.AnyAsset, + ) => Promise } /** Data associated with a {@link AssetRow}, used for rendering. */ @@ -413,6 +335,7 @@ export default function AssetsTable(props: AssetsTableProps) { const didLoadingProjectManagerFail = backendProvider.useDidLoadingProjectManagerFail() const reconnectToProjectManager = backendProvider.useReconnectToProjectManager() const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS) + const [sortInfo, setSortInfo] = React.useState | null>(null) const driveStore = useDriveStore() @@ -424,15 +347,20 @@ export default function AssetsTable(props: AssetsTableProps) { const [pasteData, setPasteData] = React.useState > | null>(null) - const [, setQueuedAssetEvents] = React.useState([]) - const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName) + const { data: users } = useBackendQuery(backend, 'listUsers', []) const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', []) const organizationQuery = useSuspenseQuery({ queryKey: [backend.type, 'getOrganization'], queryFn: () => backend.getOrganization(), }) + const organization = organizationQuery.data + + const isAssetContextMenuVisible = + category.type !== 'cloud' || user.plan == null || user.plan === backendModule.Plan.solo + + const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName) const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory') const rootDirectoryId = React.useMemo(() => { const localRootPath = localRootDirectory != null ? backendModule.Path(localRootDirectory) : null @@ -443,39 +371,278 @@ export default function AssetsTable(props: AssetsTableProps) { invariant(id, 'Missing root directory') return id }, [category, backend, user, organization, localRootDirectory]) - const [assetTree, setAssetTree] = React.useState(() => { - const rootParentDirectoryId = backendModule.DirectoryId('') + + 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. + * 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 [privateExpandedDirectoryIds, setExpandedDirectoryIds] = React.useState< + backendModule.DirectoryId[] + >(() => []) + + const expandedDirectoryIds = React.useMemo( + () => privateExpandedDirectoryIds.concat(rootDirectoryId), + [privateExpandedDirectoryIds, rootDirectoryId], + ) + + 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 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: [['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. + queries: React.useMemo( + () => + expandedDirectoryIds.map((directoryId) => + queryOptions({ + queryKey: [ + 'listDirectory', + backend.type, + directoryId, + { + parentId: directoryId, + labels: null, + filterBy: CATEGORY_TO_FILTER_BY[category.type], + recentProjects: category.type === '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, + })), + } + }, + }) + + /** + * Return type 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(() => { const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath - return AssetTreeNode.fromAsset( - backendModule.createRootDirectoryAsset(rootDirectoryId), + + // 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, + 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, + `${rootPath}/${content.title}`, + null, + content.id, + ) + + return appendChildrenRecursively(node, 1) + }) + + return new AssetTreeNode( + rootDirectory, rootParentDirectoryId, rootParentDirectoryId, + children, -1, 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()) - const isAssetContextMenuVisible = - category.type !== 'cloud' || user.plan == null || user.plan === backendModule.Plan.solo + }, [ + directories, + rootDirectoryContent, + rootDirectory, + rootParentDirectoryId, + backend.rootPath, + rootDirectoryId, + category, + ]) + const filter = React.useMemo(() => { const globCache: Record = {} if (/^\s*$/.test(query.query)) { @@ -585,9 +752,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 @@ -621,38 +816,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, @@ -661,7 +857,6 @@ export default function AssetsTable(props: AssetsTableProps) { true, ) - const updateSecret = useMutation(backendMutationOptions(backend, 'updateSecret')).mutateAsync React.useEffect(() => { previousCategoryRef.current = category }) @@ -959,78 +1154,6 @@ export default function AssetsTable(props: AssetsTableProps) { [driveStore, isCloud, setCanDownload], ) - const overwriteNodes = useEventCallback((newAssets: readonly backendModule.AnyAsset[]) => { - mostRecentlySelectedIndexRef.current = null - selectionStartIndexRef.current = null - const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath - // 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, `${rootPath}/${asset.title}`, null), - ) - const newRootNode = new AssetTreeNode( - rootDirectory, - rootParentDirectoryId, - rootParentDirectoryId, - children, - -1, - 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 - }) - const overwriteNodesRef = React.useRef(overwriteNodes) - overwriteNodesRef.current = overwriteNodes - - const rootDirectoryQuery = useBackendQuery( - backend, - 'listDirectory', - [ - { - parentId: rootDirectoryId, - filterBy: CATEGORY_TO_FILTER_BY[category.type], - recentProjects: category.type === '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 @@ -1059,12 +1182,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) { @@ -1087,128 +1204,102 @@ 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.type], - recentProjects: category.type === '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), ) } - })() + }) + } + }, + ) + + 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 (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) + } + }, + ) + + const doDeleteById = useEventCallback( + async (assetId: backendModule.AssetId, forever: boolean = false) => { + const asset = nodeMapRef.current.get(assetId)?.item + + if (asset != null) { + // eslint-disable-next-line no-restricted-syntax + return doDelete(asset, forever) } }, - [category, backend, toastAndLog], ) const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial) @@ -1219,8 +1310,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) + }) }, [], ) @@ -1286,7 +1379,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) } @@ -1308,7 +1401,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 @@ -1333,7 +1426,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 } @@ -1427,7 +1520,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+)$`) @@ -1442,68 +1535,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. @@ -1537,10 +1605,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: { @@ -1549,6 +1621,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, @@ -1571,19 +1644,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: { @@ -1610,14 +1701,88 @@ export default function AssetsTable(props: AssetsTableProps) { userGroups ?? [], ) 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( @@ -1628,6 +1793,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( @@ -1640,9 +1806,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( @@ -1687,8 +1858,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) => { @@ -1700,6 +1878,7 @@ export default function AssetsTable(props: AssetsTableProps) { fileMap.set(asset.id, file) return asset }) + const newProjects = projects .filter( (project) => @@ -1717,8 +1896,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') + } }} />, ) @@ -1744,14 +1929,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: { @@ -1773,14 +1962,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: { @@ -1793,6 +1990,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()), @@ -1814,16 +2012,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: { @@ -1833,56 +2041,35 @@ export default function AssetsTable(props: AssetsTableProps) { newSelectedKeys.delete(event.key) setSelectedKeys(newSelectedKeys) } + + deleteAsset(event.key) + break } case AssetListEventType.copy: { - const ids = new Set() - const getKey = (asset: backendModule.AnyAsset) => { - const newId = backendModule.createPlaceholderAssetId(asset.type) - ids.add(newId) - return newId + insertAssets(event.items, event.newParentId) + + for (const item of event.items) { + void doCopyOnBackend(event.newParentId, item) } - const assetEvents: readonly assetEvent.AssetEvent[] = [ - { - type: AssetEventType.copy, - ids, - newParentKey: event.newParentKey, - newParentId: event.newParentId, - }, - ] - const newParent = nodeMapRef.current.get(event.newParentKey) - const newOwner = - !isCloud || !newParent ? - null - : permissions.newOwnerFromPath(newParent.path, users ?? [], userGroups ?? []) - insertArbitraryAssets( - newOwner ? - event.items.map((item) => permissions.replaceOwnerPermission(item, newOwner)) - : event.items, - event.newParentKey, - event.newParentId, - getKey, - () => assetEvents, - ) break } case AssetListEventType.move: { deleteAsset(event.key) - const newParent = nodeMapRef.current.get(event.newParentKey) - const newOwner = - !isCloud || !newParent ? - null - : permissions.newOwnerFromPath(newParent.path, users ?? [], userGroups ?? []) - insertAssets( - [newOwner ? permissions.replaceOwnerPermission(event.item, newOwner) : event.item], - event.newParentKey, - event.newParentId, - () => null, - ) + insertAssets(event.items, event.newParentId) + + for (const item of event.items) { + void doMove(event.newParentId, item) + } + break } case AssetListEventType.delete: { deleteAsset(event.key) + const asset = nodeMapRef.current.get(event.key)?.item + if (asset) { + void doDelete(asset, false) + } break } case AssetListEventType.emptyTrash: { @@ -1906,7 +2093,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 } } @@ -1945,7 +2132,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] : []), @@ -1971,6 +2158,14 @@ export default function AssetsTable(props: AssetsTableProps) { [pasteData, doToggleDirectoryExpansion, unsetModal, dispatchAssetEvent, dispatchAssetListEvent], ) + const doRestore = useEventCallback(async (asset: backendModule.AnyAsset) => { + try { + await undoDeleteAssetMutation.mutateAsync([asset.id, asset.title]) + } catch (error) { + toastAndLog('restoreAssetError', error, asset.title) + } + }) + const hideColumn = React.useCallback((column: columnUtils.Column) => { setEnabledColumns((columns) => set.withPresence(columns, column, false)) }, []) @@ -1988,9 +2183,10 @@ export default function AssetsTable(props: AssetsTableProps) { doCopy={doCopy} doCut={doCut} doPaste={doPaste} + doDelete={doDeleteById} /> ), - [backend, rootDirectoryId, category, pasteData, doCopy, doCut, doPaste], + [backend, category, pasteData, rootDirectoryId, doCopy, doCut, doPaste, doDeleteById], ) const onDropzoneDragOver = (event: React.DragEvent) => { @@ -2027,6 +2223,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, @@ -2046,9 +2243,13 @@ export default function AssetsTable(props: AssetsTableProps) { doCopy, doCut, doPaste, + doDelete, + doRestore, + doMove, }), [ backend, + expandedDirectoryIds, rootDirectoryId, visibilities, category, @@ -2059,6 +2260,9 @@ export default function AssetsTable(props: AssetsTableProps) { doCopy, doCut, doPaste, + doDelete, + doRestore, + doMove, hideColumn, setAssetPanelProps, setIsAssetPanelTemporarilyVisible, @@ -2275,19 +2479,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, () => ({ @@ -2324,7 +2535,7 @@ export default function AssetsTable(props: AssetsTableProps) { : displayItems.map((item, i) => { return ( { if (instance != null) { updateAssetRef.current[item.item.id] = instance @@ -2516,6 +2727,7 @@ export default function AssetsTable(props: AssetsTableProps) { doCopy={doCopy} doCut={doCut} doPaste={doPaste} + doDelete={doDeleteById} />, ) } @@ -2586,6 +2798,7 @@ export default function AssetsTable(props: AssetsTableProps) { event.preventDefault() event.stopPropagation() unsetModal() + dispatchAssetEvent({ type: AssetEventType.move, newParentKey: rootDirectoryId, diff --git a/app/dashboard/src/layouts/AssetsTableContextMenu.tsx b/app/dashboard/src/layouts/AssetsTableContextMenu.tsx index b39ee665a3b9..bbe4f1d4b02c 100644 --- a/app/dashboard/src/layouts/AssetsTableContextMenu.tsx +++ b/app/dashboard/src/layouts/AssetsTableContextMenu.tsx @@ -49,6 +49,7 @@ export interface AssetsTableContextMenuProps { newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId, ) => void + readonly doDelete: (assetId: backendModule.AssetId, forever?: boolean) => Promise } /** A context menu for an `AssetsTable`, when no row is selected, or multiple rows @@ -56,7 +57,7 @@ export interface AssetsTableContextMenuProps { export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) { const { hidden = false, backend, category, pasteData } = props const { nodeMapRef, event, rootDirectoryId } = props - const { doCopy, doCut, doPaste } = props + const { doCopy, doCut, doPaste, doDelete } = props const { user } = authProvider.useFullUserSession() const { setModal, unsetModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() @@ -82,7 +83,10 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp const doDeleteAll = () => { if (isCloud) { unsetModal() - dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys }) + + for (const key of selectedKeys) { + void doDelete(key, false) + } } else { const [firstKey] = selectedKeys const soleAssetName = @@ -97,7 +101,10 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp } doDelete={() => { setSelectedKeys(EMPTY_SET) - dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys }) + + for (const key of selectedKeys) { + void doDelete(key, false) + } }} />, ) diff --git a/app/dashboard/src/layouts/DriveBar.tsx b/app/dashboard/src/layouts/DriveBar.tsx index 20d210c35d45..089061805a53 100644 --- a/app/dashboard/src/layouts/DriveBar.tsx +++ b/app/dashboard/src/layouts/DriveBar.tsx @@ -271,6 +271,7 @@ export default function DriveBar(props: DriveBarProps) { 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 7223a9cb75a4..8e82d7fa160c 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 df98be8d19a0..6cefadad726f 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 38516e4507e8..24865815a3f5 100644 --- a/app/dashboard/src/pages/dashboard/Dashboard.tsx +++ b/app/dashboard/src/pages/dashboard/Dashboard.tsx @@ -327,6 +327,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..d028586a2020 --- /dev/null +++ b/app/dashboard/src/providers/FeatureFlagsProvider.tsx @@ -0,0 +1,114 @@ +/** + * @file + * + * Feature flags provider. + * Feature flags are used to enable or disable certain features in the application. + */ +import { useMount } from '#/hooks/mountHooks' +import { useLocalStorage } from '#/providers/LocalStorageProvider' +import LocalStorage from '#/utilities/LocalStorage' +import { unsafeEntries } from '#/utilities/object' +import type { ReactNode } from 'react' +import { useEffect } from 'react' +import { z } from 'zod' +import { createStore, useStore } from 'zustand' + +declare module '#/utilities/LocalStorage' { + /** + * Local storage data structure. + */ + interface LocalStorageData { + readonly featureFlags: z.infer + } +} + +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: Key, + value: FeatureFlags['featureFlags'][Key], + ) => void +} + +const flagsStore = createStore((set) => ({ + featureFlags: { + enableMultitabs: false, + enableAssetsTableBackgroundRefresh: true, + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + assetsTableBackgroundRefreshInterval: 3_000, + }, + setFeatureFlags: (key, value) => { + set(({ featureFlags }) => ({ featureFlags: { ...featureFlags, [key]: value } })) + }, +})) + +/** + * Hook to get all feature flags. + */ +export function useFeatureFlags() { + return useStore(flagsStore, (state) => state.featureFlags) +} + +/** + * Hook to get a specific feature flag. + */ +export function useFeatureFlag( + key: Key, +): FeatureFlags['featureFlags'][Key] { + return useStore(flagsStore, ({ featureFlags }) => featureFlags[key]) +} + +/** + * Hook to set feature flags. + */ +export function useSetFeatureFlags() { + return useStore(flagsStore, ({ setFeatureFlags }) => setFeatureFlags) +} + +/** + * Feature flags provider. + * Gets feature flags from local storage and sets them in the store. + * Also saves feature flags to local storage when they change. + */ +export function FeatureFlagsProvider({ children }: { children: ReactNode }) { + const { localStorage } = useLocalStorage() + const setFeatureFlags = useSetFeatureFlags() + + useMount(() => { + const storedFeatureFlags = localStorage.get('featureFlags') + + if (storedFeatureFlags) { + for (const [key, value] of unsafeEntries(storedFeatureFlags)) { + setFeatureFlags(key, value) + } + } + }) + + useEffect( + () => + flagsStore.subscribe((state, prevState) => { + if (state.featureFlags !== prevState.featureFlags) { + localStorage.set('featureFlags', state.featureFlags) + } + }), + [localStorage], + ) + + return <>{children} +} 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 0969f7ce311e..75c02b1611f0 100644 --- a/app/ide-desktop/common/src/text/english.json +++ b/app/ide-desktop/common/src/text/english.json @@ -459,6 +459,14 @@ "organizationInviteErrorSuffix": "' is inviting you.", "organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at", + "enableMultitabs": "Enable Multi-Tabs", + "enableMultitabsDescription": "Open multiple projects at the same time.", + + "enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh", + "enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.", + "enableAssetsTableBackgroundRefreshInterval": "Refresh interval", + "enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.", + "deleteLabelActionText": "delete the label '$0'", "deleteSelectedAssetActionText": "delete '$0'", "deleteSelectedAssetsActionText": "delete $0 selected items", @@ -856,10 +864,11 @@ "shareFullFeatureDescription": "Share unlimited assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.", "shareFullPaywallMessage": "You can only share assets with a single user group. Upgrade to share assets with multiple user groups and users.", - "paywallDevtoolsButtonLabel": "Open Enso Devtools", - "paywallDevtoolsPopoverHeading": "Enso Devtools", - "paywallDevtoolsPlanSelectSubtitle": "User Plan", - "paywallDevtoolsPaywallFeaturesToggles": "Paywall Features", + "ensoDevtoolsButtonLabel": "Open Enso Devtools", + "ensoDevtoolsPopoverHeading": "Enso Devtools", + "ensoDevtoolsPlanSelectSubtitle": "User Plan", + "ensoDevtoolsPaywallFeaturesToggles": "Paywall Features", + "ensoDevtoolsFeatureFlags": "Feature Flags", "setupEnso": "Set up Enso", "termsAndConditions": "Terms and Conditions", 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