Adjust Refresh Interval in Assets table (#10775)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ export const INPUT_STYLES = tv({
variant: { variant: {
custom: {}, custom: {},
outline: { 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', textArea: 'border-transparent focus-within:border-transparent',
}, },
}, },

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import * as text from '../Text'
// ================= // =================
export const TOOLTIP_STYLES = twv.tv({ 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: { variants: {
variant: { variant: {
custom: '', custom: '',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,12 @@
/** @file The icon and name of a {@link backendModule.SecretAsset}. */ /** @file The icon and name of a {@link backendModule.SecretAsset}. */
import * as React from 'react' import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import DatalinkIcon from '#/assets/datalink.svg' import DatalinkIcon from '#/assets/datalink.svg'
import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks' import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider' 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 type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan' import EditableSpan from '#/components/EditableSpan'
@ -25,7 +16,6 @@ import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent' import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
// ==================== // ====================
// === DatalinkName === // === DatalinkName ===
@ -39,10 +29,8 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */ * This should never happen. */
export default function DatalinkNameColumn(props: DatalinkNameColumnProps) { export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, setIsAssetPanelTemporarilyVisible } = state const { setIsAssetPanelTemporarilyVisible } = state
const toastAndLog = toastAndLogHooks.useToastAndLog()
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.datalink) { if (item.type !== backendModule.AssetType.datalink) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
throw new Error('`DatalinkNameColumn` can only display Datalinks.') throw new Error('`DatalinkNameColumn` can only display Datalinks.')
@ -50,8 +38,6 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
const asset = item.item const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem) const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const setIsEditing = (isEditingName: boolean) => { const setIsEditing = (isEditingName: boolean) => {
if (isEditable) { if (isEditable) {
setRowState(object.merger({ isEditingName })) 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 // TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added. // context menu entry should be re-added.
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505. // Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
const doRename = async () => { const doRename = () => Promise.resolve(null)
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 handleClick = inputBindings.handler({ const handleClick = inputBindings.handler({
editName: () => { editName: () => {
@ -171,7 +96,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
onCancel={() => { onCancel={() => {
setIsEditing(false) setIsEditing(false)
}} }}
className="text grow bg-transparent font-naming" className="grow bg-transparent font-naming"
> >
{asset.title} {asset.title}
</EditableSpan> </EditableSpan>

View File

@ -12,11 +12,6 @@ import { useDriveStore } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider' 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 * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan' import EditableSpan from '#/components/EditableSpan'
@ -30,7 +25,6 @@ import * as object from '#/utilities/object'
import * as string from '#/utilities/string' import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import * as validation from '#/utilities/validation' import * as validation from '#/utilities/validation'
import Visibility from '#/utilities/Visibility'
// ===================== // =====================
// === DirectoryName === // === DirectoryName ===
@ -45,21 +39,22 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
const { backend, nodeMap } = state const { backend, nodeMap } = state
const { doToggleDirectoryExpansion } = state const { doToggleDirectoryExpansion, expandedDirectoryIds } = state
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = inputBindingsProvider.useInputBindings()
const driveStore = useDriveStore() const driveStore = useDriveStore()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.directory) { if (item.type !== backendModule.AssetType.directory) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
throw new Error('`DirectoryNameColumn` can only display folders.') throw new Error('`DirectoryNameColumn` can only display folders.')
} }
const asset = item.item const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem) 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 updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
const setIsEditing = (isEditingName: boolean) => { 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({ const handleClick = inputBindings.handler({
editName: () => { editName: () => {
setIsEditing(true) setIsEditing(true)
@ -185,7 +127,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
isExpanded && 'rotate-90', isExpanded && 'rotate-90',
)} )}
onPress={() => { onPress={() => {
doToggleDirectoryExpansion(asset.id, item.key, asset.title) doToggleDirectoryExpansion(asset.id, item.key)
}} }}
/> />
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" /> <SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />
@ -193,7 +135,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
data-testid="asset-row-name" data-testid="asset-row-name"
editable={rowState.isEditingName} editable={rowState.isEditingName}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'text grow cursor-pointer bg-transparent font-naming', 'grow cursor-pointer bg-transparent font-naming',
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer', rowState.isEditingName ? 'cursor-text' : 'cursor-pointer',
)} )}
checkSubmittable={(newTitle) => checkSubmittable={(newTitle) =>

View File

@ -9,11 +9,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider' 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 type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan' import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
@ -26,7 +21,6 @@ import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import * as string from '#/utilities/string' import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
// ================ // ================
// === FileName === // === FileName ===
@ -43,7 +37,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
const { backend, nodeMap } = state const { backend, nodeMap } = state
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.file) { if (item.type !== backendModule.AssetType.file) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
throw new Error('`FileNameColumn` can only display files.') 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 isCloud = backend.type === backendModule.BackendType.remote
const updateFileMutation = useMutation(backendMutationOptions(backend, 'updateFile')) 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) => { const setIsEditing = (isEditingName: boolean) => {
if (isEditable) { 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({ const handleClick = inputBindings.handler({
editName: () => { editName: () => {
setIsEditing(true) setIsEditing(true)
@ -182,7 +106,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
<EditableSpan <EditableSpan
data-testid="asset-row-name" data-testid="asset-row-name"
editable={rowState.isEditingName} editable={rowState.isEditingName}
className="text grow bg-transparent font-naming" className="grow bg-transparent font-naming"
checkSubmittable={(newTitle) => checkSubmittable={(newTitle) =>
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children) item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
} }

View File

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

View File

@ -1,7 +1,7 @@
/** @file The icon and name of a {@link backendModule.ProjectAsset}. */ /** @file The icon and name of a {@link backendModule.ProjectAsset}. */
import * as React from 'react' import * as React from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import NetworkIcon from '#/assets/network.svg' import NetworkIcon from '#/assets/network.svg'
@ -15,19 +15,12 @@ import { useDriveStore } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider' 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 type * as column from '#/components/dashboard/column'
import ProjectIcon from '#/components/dashboard/ProjectIcon' import ProjectIcon from '#/components/dashboard/ProjectIcon'
import EditableSpan from '#/components/EditableSpan' import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
import * as backendModule from '#/services/Backend' 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 eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent' import * as indent from '#/utilities/indent'
@ -36,7 +29,6 @@ import * as permissions from '#/utilities/permissions'
import * as string from '#/utilities/string' import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import * as validation from '#/utilities/validation' import * as validation from '#/utilities/validation'
import Visibility from '#/utilities/Visibility'
// =================== // ===================
// === ProjectName === // === ProjectName ===
@ -61,12 +53,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
isOpened, isOpened,
} = props } = props
const { backend, nodeMap } = state const { backend, nodeMap } = state
const client = useQueryClient()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useFullUserSession() const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const driveStore = useDriveStore() const driveStore = useDriveStore()
const doOpenProject = projectHooks.useOpenProject() const doOpenProject = projectHooks.useOpenProject()
@ -74,6 +65,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
throw new Error('`ProjectNameColumn` can only display projects.') throw new Error('`ProjectNameColumn` can only display projects.')
} }
const asset = item.item const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem) const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const ownPermission = const ownPermission =
@ -96,20 +88,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const isOtherUserUsingProject = const isOtherUserUsingProject =
isCloud && projectState.openedBy != null && projectState.openedBy !== user.email isCloud && projectState.openedBy != null && projectState.openedBy !== user.email
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
const updateProjectMutation = useMutation(backendMutationOptions(backend, 'updateProject')) 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) => { const setIsEditing = (isEditingName: boolean) => {
if (isEditable) { if (isEditable) {
@ -131,9 +110,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
{ ami: null, ideVersion: null, projectName: newTitle }, { ami: null, ideVersion: null, projectName: newTitle },
asset.title, asset.title,
]) ])
await client.invalidateQueries({
queryKey: projectHooks.createGetProjectDetailsQuery.getQueryKey(asset.id),
})
} catch (error) { } catch (error) {
toastAndLog('renameProjectError', error) toastAndLog('renameProjectError', error)
setAsset(object.merger({ title: oldTitle })) 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({ const handleClick = inputBindings.handler({
editName: () => { editName: () => {
setIsEditing(true) setIsEditing(true)
@ -354,7 +170,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
data-testid="asset-row-name" data-testid="asset-row-name"
editable={rowState.isEditingName} editable={rowState.isEditingName}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'text grow bg-transparent font-naming', 'grow bg-transparent font-naming',
canExecute && !isOtherUserUsingProject && 'cursor-pointer', canExecute && !isOtherUserUsingProject && 'cursor-pointer',
rowState.isEditingName && 'cursor-text', rowState.isEditingName && 'cursor-text',
)} )}

View File

@ -6,17 +6,11 @@ import { useMutation } from '@tanstack/react-query'
import KeyIcon from '#/assets/key.svg' import KeyIcon from '#/assets/key.svg'
import { backendMutationOptions } from '#/hooks/backendHooks' import { backendMutationOptions } from '#/hooks/backendHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider' 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 * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
@ -29,7 +23,6 @@ import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent' import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
// ===================== // =====================
// === ConnectorName === // === ConnectorName ===
@ -42,19 +35,17 @@ export interface SecretNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.SecretAsset}. * @throws {Error} when the asset is not a {@link backendModule.SecretAsset}.
* This should never happen. */ * This should never happen. */
export default function SecretNameColumn(props: SecretNameColumnProps) { 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 { backend } = state
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
if (item.type !== backendModule.AssetType.secret) { if (item.type !== backendModule.AssetType.secret) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
throw new Error('`SecretNameColumn` can only display secrets.') throw new Error('`SecretNameColumn` can only display secrets.')
} }
const asset = item.item const asset = item.item
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret')) const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const setIsEditing = (isEditingName: boolean) => { 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({ const handleClick = inputBindings.handler({
editName: () => { editName: () => {
setIsEditing(true) setIsEditing(true)

View File

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

View File

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

View File

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

View File

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

View File

@ -12,11 +12,12 @@ import * as React from 'react'
export function useSyncRef<T>(value: T): Readonly<React.MutableRefObject<T>> { export function useSyncRef<T>(value: T): Readonly<React.MutableRefObject<T>> {
const ref = React.useRef(value) const ref = React.useRef(value)
// Update the ref value whenever the provided value changes /*
// Refs shall never change during the render phase, so we use `useEffect` here. Even though the react core team doesn't recommend setting ref values during the render (it might lead to deoptimizations), the reasoning behind this is:
React.useEffect(() => { - 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 ref.current = value
})
return ref return ref
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -459,6 +459,14 @@
"organizationInviteErrorSuffix": "' is inviting you.", "organizationInviteErrorSuffix": "' is inviting you.",
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at", "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'", "deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'", "deleteSelectedAssetActionText": "delete '$0'",
"deleteSelectedAssetsActionText": "delete $0 selected items", "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.", "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.", "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", "ensoDevtoolsButtonLabel": "Open Enso Devtools",
"paywallDevtoolsPopoverHeading": "Enso Devtools", "ensoDevtoolsPopoverHeading": "Enso Devtools",
"paywallDevtoolsPlanSelectSubtitle": "User Plan", "ensoDevtoolsPlanSelectSubtitle": "User Plan",
"paywallDevtoolsPaywallFeaturesToggles": "Paywall Features", "ensoDevtoolsPaywallFeaturesToggles": "Paywall Features",
"ensoDevtoolsFeatureFlags": "Feature Flags",
"setupEnso": "Set up Enso", "setupEnso": "Set up Enso",
"termsAndConditions": "Terms and Conditions", "termsAndConditions": "Terms and Conditions",

View File

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