mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 11:41:56 +03:00
Adjust Refresh Interval in Assets table (#10775)
This commit is contained in:
parent
88aaa51341
commit
77183e50e9
@ -504,24 +504,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
(a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type],
|
||||
)
|
||||
const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets }
|
||||
|
||||
return json
|
||||
})
|
||||
await get(
|
||||
remoteBackendPaths.LIST_FILES_PATH + '*',
|
||||
() => ({ files: [] }) satisfies remoteBackend.ListFilesResponseBody,
|
||||
)
|
||||
await get(
|
||||
remoteBackendPaths.LIST_PROJECTS_PATH + '*',
|
||||
() => ({ projects: [] }) satisfies remoteBackend.ListProjectsResponseBody,
|
||||
)
|
||||
await get(
|
||||
remoteBackendPaths.LIST_SECRETS_PATH + '*',
|
||||
() => ({ secrets: [] }) satisfies remoteBackend.ListSecretsResponseBody,
|
||||
)
|
||||
await get(
|
||||
remoteBackendPaths.LIST_TAGS_PATH + '*',
|
||||
() => ({ tags: labels }) satisfies remoteBackend.ListTagsResponseBody,
|
||||
)
|
||||
await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => {
|
||||
return { files: [] } satisfies remoteBackend.ListFilesResponseBody
|
||||
})
|
||||
await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => {
|
||||
return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody
|
||||
})
|
||||
await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => {
|
||||
return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody
|
||||
})
|
||||
await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => {
|
||||
return { tags: labels } satisfies remoteBackend.ListTagsResponseBody
|
||||
})
|
||||
await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => {
|
||||
if (currentUser != null) {
|
||||
return { users } satisfies remoteBackend.ListUsersResponseBody
|
||||
@ -584,6 +581,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
interface Body {
|
||||
readonly parentDirectoryId: backend.DirectoryId
|
||||
}
|
||||
|
||||
const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null
|
||||
@ -605,7 +603,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
const body: Body = await request.postDataJSON()
|
||||
const parentId = body.parentDirectoryId
|
||||
// Can be any asset ID.
|
||||
const id = backend.DirectoryId(uniqueString.uniqueString())
|
||||
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
|
||||
const json: backend.CopyAssetResponse = {
|
||||
asset: {
|
||||
id,
|
||||
@ -621,6 +619,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
await route.fulfill({ json })
|
||||
}
|
||||
})
|
||||
|
||||
await get(remoteBackendPaths.INVITATION_PATH + '*', async (route) => {
|
||||
await route.fulfill({
|
||||
json: { invitations: [] } satisfies backend.ListInvitationsResponseBody,
|
||||
@ -695,7 +694,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
const searchParams: SearchParams = Object.fromEntries(
|
||||
new URL(request.url()).searchParams.entries(),
|
||||
) as never
|
||||
const file = createFile(searchParams.file_name)
|
||||
|
||||
const file = addFile(searchParams.file_name)
|
||||
|
||||
return { path: '', id: file.id, project: null } satisfies backend.FileInfo
|
||||
})
|
||||
|
||||
@ -703,7 +704,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.CreateSecretRequestBody = await request.postDataJSON()
|
||||
const secret = createSecret(body.name)
|
||||
const secret = addSecret(body.name)
|
||||
return secret.id
|
||||
})
|
||||
|
||||
@ -721,6 +722,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
if (body.description != null) {
|
||||
object.unsafeMutable(asset).description = body.description
|
||||
}
|
||||
|
||||
if (body.parentDirectoryId != null) {
|
||||
object.unsafeMutable(asset).parentId = body.parentDirectoryId
|
||||
}
|
||||
}
|
||||
})
|
||||
await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => {
|
||||
@ -813,7 +818,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
currentUser = { ...currentUser, name: body.username }
|
||||
}
|
||||
})
|
||||
await get(remoteBackendPaths.USERS_ME_PATH + '*', () => currentUser)
|
||||
await get(remoteBackendPaths.USERS_ME_PATH + '*', () => {
|
||||
return currentUser
|
||||
})
|
||||
await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => {
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
|
@ -23,7 +23,7 @@ test.test('copy', ({ page }) =>
|
||||
.driveTable.withRows(async (rows) => {
|
||||
await test.expect(rows).toHaveCount(3)
|
||||
await test.expect(rows.nth(2)).toBeVisible()
|
||||
await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
|
||||
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
|
||||
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
@ -46,7 +46,7 @@ test.test('copy (keyboard)', ({ page }) =>
|
||||
.driveTable.withRows(async (rows) => {
|
||||
await test.expect(rows).toHaveCount(3)
|
||||
await test.expect(rows.nth(2)).toBeVisible()
|
||||
await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
|
||||
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
|
||||
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
@ -69,7 +69,7 @@ test.test('move', ({ page }) =>
|
||||
.driveTable.withRows(async (rows) => {
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
await test.expect(rows.nth(1)).toBeVisible()
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
@ -88,7 +88,7 @@ test.test('move (drag)', ({ page }) =>
|
||||
.driveTable.withRows(async (rows) => {
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
await test.expect(rows.nth(1)).toBeVisible()
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
@ -129,7 +129,7 @@ test.test('move (keyboard)', ({ page }) =>
|
||||
.driveTable.withRows(async (rows) => {
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
await test.expect(rows.nth(1)).toBeVisible()
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
@ -164,11 +164,11 @@ test.test('duplicate', ({ page }) =>
|
||||
.driveTable.rightClickRow(0)
|
||||
.contextMenu.duplicate()
|
||||
.driveTable.withRows(async (rows) => {
|
||||
// Assets: [0: New Project 1 (copy), 1: New Project 1]
|
||||
// Assets: [0: New Project 1, 1: New Project 1 (copy)]
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
await test.expect(rows.nth(0)).toBeVisible()
|
||||
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
|
||||
await test.expect(rows.nth(1)).toBeVisible()
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
|
||||
}),
|
||||
)
|
||||
|
||||
@ -184,7 +184,7 @@ test.test('duplicate (keyboard)', ({ page }) =>
|
||||
.driveTable.withRows(async (rows) => {
|
||||
// Assets: [0: New Project 1 (copy), 1: New Project 1]
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
await test.expect(rows.nth(0)).toBeVisible()
|
||||
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
|
||||
await test.expect(rows.nth(1)).toBeVisible()
|
||||
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
|
||||
}),
|
||||
)
|
||||
|
@ -33,7 +33,7 @@ test.test('drive view', ({ page }) =>
|
||||
// user that project creation may take a while. Previously opened projects are stopped when the
|
||||
// new project is created.
|
||||
.driveTable.withRows(async (rows) => {
|
||||
await actions.locateStopProjectButton(rows.nth(0)).click()
|
||||
await actions.locateStopProjectButton(rows.nth(1)).click()
|
||||
})
|
||||
// Project context menu
|
||||
.driveTable.rightClickRow(0)
|
||||
|
@ -49,7 +49,6 @@ import * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
|
||||
import DriveProvider from '#/providers/DriveProvider'
|
||||
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
|
||||
import { useHttpClient } from '#/providers/HttpClientProvider'
|
||||
import InputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
@ -91,10 +90,11 @@ import * as appBaseUrl from '#/utilities/appBaseUrl'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
import { Path } from '#/utilities/path'
|
||||
|
||||
import { useInitAuthService } from '#/authentication/service'
|
||||
import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
|
||||
import { Path } from '#/utilities/path'
|
||||
import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
@ -492,7 +492,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<DevtoolsProvider>
|
||||
<FeatureFlagsProvider>
|
||||
<RouterProvider navigate={navigate}>
|
||||
<SessionProvider
|
||||
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
|
||||
@ -517,7 +517,9 @@ function AppRouter(props: AppRouterProps) {
|
||||
{routes}
|
||||
{detect.IS_DEV_MODE && (
|
||||
<suspense.Suspense>
|
||||
<devtools.EnsoDevtools />
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<devtools.EnsoDevtools />
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</suspense.Suspense>
|
||||
)}
|
||||
</errorBoundary.ErrorBoundary>
|
||||
@ -527,7 +529,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
</BackendProvider>
|
||||
</SessionProvider>
|
||||
</RouterProvider>
|
||||
</DevtoolsProvider>
|
||||
</FeatureFlagsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -5,24 +5,39 @@ import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
import type * as types from './types'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
const PLACEHOLDER = <div />
|
||||
|
||||
/**
|
||||
* Props passed to the render function of a {@link DialogTrigger}.
|
||||
*/
|
||||
export interface DialogTriggerRenderProps {
|
||||
readonly isOpened: boolean
|
||||
}
|
||||
/**
|
||||
* Props for a {@link DialogTrigger}.
|
||||
*/
|
||||
export interface DialogTriggerProps extends types.DialogTriggerProps {}
|
||||
export interface DialogTriggerProps extends Omit<aria.DialogTriggerProps, 'children'> {
|
||||
/**
|
||||
* The trigger element.
|
||||
*/
|
||||
readonly children: [
|
||||
React.ReactElement,
|
||||
React.ReactElement | ((props: DialogTriggerRenderProps) => React.ReactElement),
|
||||
]
|
||||
}
|
||||
|
||||
/** A DialogTrigger opens a dialog when a trigger element is pressed. */
|
||||
export function DialogTrigger(props: DialogTriggerProps) {
|
||||
const { children, onOpenChange, ...triggerProps } = props
|
||||
|
||||
const [isOpened, setIsOpened] = React.useState(false)
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const onOpenChangeInternal = React.useCallback(
|
||||
(isOpened: boolean) => {
|
||||
if (isOpened) {
|
||||
(opened: boolean) => {
|
||||
if (opened) {
|
||||
// We're using a placeholder here just to let the rest of the code know that the modal
|
||||
// is open.
|
||||
setModal(PLACEHOLDER)
|
||||
@ -30,14 +45,36 @@ export function DialogTrigger(props: DialogTriggerProps) {
|
||||
unsetModal()
|
||||
}
|
||||
|
||||
onOpenChange?.(isOpened)
|
||||
setIsOpened(opened)
|
||||
onOpenChange?.(opened)
|
||||
},
|
||||
[setModal, unsetModal, onOpenChange],
|
||||
)
|
||||
|
||||
const renderProps = {
|
||||
isOpened,
|
||||
} satisfies DialogTriggerRenderProps
|
||||
|
||||
const [trigger, dialog] = children
|
||||
|
||||
return (
|
||||
<aria.DialogTrigger onOpenChange={onOpenChangeInternal} {...triggerProps}>
|
||||
{children}
|
||||
{trigger}
|
||||
|
||||
{/* We're using AnimatePresence here to animate the dialog in and out. */}
|
||||
<AnimatePresence>
|
||||
{isOpened && (
|
||||
<motion.div
|
||||
style={{ display: 'none' }}
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
>
|
||||
{typeof dialog === 'function' ? dialog(renderProps) : dialog}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</aria.DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
@ -10,6 +10,3 @@ export interface DialogProps extends aria.DialogProps {
|
||||
readonly modalProps?: Pick<aria.ModalOverlayProps, 'className' | 'defaultOpen' | 'isOpen'>
|
||||
readonly testId?: string
|
||||
}
|
||||
|
||||
/** The props for the DialogTrigger component. */
|
||||
export interface DialogTriggerProps extends aria.DialogTriggerProps {}
|
||||
|
@ -14,23 +14,11 @@ import * as aria from '#/components/aria'
|
||||
import * as errorUtils from '#/utilities/error'
|
||||
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import type { Mutable } from 'enso-common/src/utilities/data/object'
|
||||
import * as dialog from '../Dialog'
|
||||
import * as components from './components'
|
||||
import * as styles from './styles'
|
||||
import type * as types from './types'
|
||||
|
||||
/**
|
||||
* Maps the value to the event object.
|
||||
*/
|
||||
function mapValueOnEvent(value: unknown) {
|
||||
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
|
||||
return value
|
||||
} else {
|
||||
return { target: { value } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Form component. It wraps a `form` and provides form context.
|
||||
* It also handles form submission.
|
||||
* Provides better error handling and form state management and better UX out of the box. */
|
||||
@ -71,19 +59,12 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
||||
formOptions.defaultValues = defaultValues
|
||||
}
|
||||
|
||||
const innerForm = components.useForm(
|
||||
form ?? {
|
||||
shouldFocusError: true,
|
||||
schema,
|
||||
...formOptions,
|
||||
},
|
||||
defaultValues,
|
||||
)
|
||||
|
||||
const dialogContext = dialog.useDialogContext()
|
||||
const innerForm = components.useForm(form ?? { shouldFocusError: true, schema, ...formOptions })
|
||||
|
||||
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
|
||||
|
||||
const dialogContext = dialog.useDialogContext()
|
||||
|
||||
const formMutation = reactQuery.useMutation({
|
||||
// We use template literals to make the mutation key more readable in the devtools
|
||||
// This mutation exists only for debug purposes - React Query dev tools record the mutation,
|
||||
@ -140,51 +121,13 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
||||
{ isDisabled: canSubmitOffline },
|
||||
)
|
||||
|
||||
const {
|
||||
formState,
|
||||
clearErrors,
|
||||
getValues,
|
||||
setValue,
|
||||
setError,
|
||||
register,
|
||||
unregister,
|
||||
setFocus,
|
||||
reset,
|
||||
control,
|
||||
} = innerForm
|
||||
|
||||
const formStateRenderProps: types.FormStateRenderProps<Schema> = {
|
||||
formState,
|
||||
register: (name, options) => {
|
||||
const registered = register(name, options)
|
||||
|
||||
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
|
||||
...registered,
|
||||
isDisabled: registered.disabled ?? false,
|
||||
isRequired: registered.required ?? false,
|
||||
isInvalid: Boolean(formState.errors[name]),
|
||||
onChange: (value) => registered.onChange(mapValueOnEvent(value)),
|
||||
onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
unregister,
|
||||
setError,
|
||||
clearErrors,
|
||||
getValues,
|
||||
setValue,
|
||||
setFocus,
|
||||
reset,
|
||||
control,
|
||||
form: innerForm,
|
||||
}
|
||||
|
||||
const base = styles.FORM_STYLES({
|
||||
className: typeof className === 'function' ? className(formStateRenderProps) : className,
|
||||
className: typeof className === 'function' ? className(innerForm) : className,
|
||||
gap,
|
||||
})
|
||||
|
||||
const { formState, setError } = innerForm
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const errors = Object.fromEntries(
|
||||
Object.entries(formState.errors).map(([key, error]) => {
|
||||
@ -208,35 +151,34 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
||||
}
|
||||
}}
|
||||
className={base}
|
||||
style={typeof style === 'function' ? style(formStateRenderProps) : style}
|
||||
style={typeof style === 'function' ? style(innerForm) : style}
|
||||
noValidate
|
||||
data-testid={testId}
|
||||
{...formProps}
|
||||
>
|
||||
<aria.FormValidationContext.Provider value={errors}>
|
||||
<reactHookForm.FormProvider {...innerForm}>
|
||||
{typeof children === 'function' ? children(formStateRenderProps) : children}
|
||||
{typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children}
|
||||
</reactHookForm.FormProvider>
|
||||
</aria.FormValidationContext.Provider>
|
||||
</form>
|
||||
)
|
||||
}) as unknown as Mutable<
|
||||
Pick<
|
||||
typeof components,
|
||||
| 'FIELD_STYLES'
|
||||
| 'Field'
|
||||
| 'FormError'
|
||||
| 'Reset'
|
||||
| 'schema'
|
||||
| 'Submit'
|
||||
| 'useField'
|
||||
| 'useForm'
|
||||
| 'useFormSchema'
|
||||
>
|
||||
> &
|
||||
(<Schema extends components.TSchema>(
|
||||
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>,
|
||||
) => React.JSX.Element)
|
||||
}) as unknown as (<Schema extends components.TSchema>(
|
||||
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>,
|
||||
) => React.JSX.Element) & {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
schema: typeof components.schema
|
||||
useForm: typeof components.useForm
|
||||
useField: typeof components.useField
|
||||
Submit: typeof components.Submit
|
||||
Reset: typeof components.Reset
|
||||
Field: typeof components.Field
|
||||
FormError: typeof components.FormError
|
||||
useFormSchema: typeof components.useFormSchema
|
||||
Controller: typeof components.Controller
|
||||
FIELD_STYLES: typeof components.FIELD_STYLES
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
Form.schema = components.schema
|
||||
Form.useForm = components.useForm
|
||||
@ -246,4 +188,5 @@ Form.Submit = components.Submit
|
||||
Form.Reset = components.Reset
|
||||
Form.FormError = components.FormError
|
||||
Form.Field = components.Field
|
||||
Form.Controller = components.Controller
|
||||
Form.FIELD_STYLES = components.FIELD_STYLES
|
||||
|
@ -23,7 +23,7 @@ export interface FieldComponentProps<Schema extends types.TSchema>
|
||||
types.FieldProps {
|
||||
readonly 'data-testid'?: string | undefined
|
||||
readonly name: Path<types.FieldValues<Schema>>
|
||||
readonly form?: types.FormInstance<Schema>
|
||||
readonly form?: types.FormInstance<Schema> | undefined
|
||||
readonly isInvalid?: boolean | undefined
|
||||
readonly className?: string | undefined
|
||||
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
|
||||
|
@ -3,6 +3,7 @@
|
||||
*
|
||||
* Barrel file for form components.
|
||||
*/
|
||||
export { Controller } from 'react-hook-form'
|
||||
export * from './Field'
|
||||
export * from './FormError'
|
||||
export * from './Reset'
|
||||
|
@ -42,12 +42,41 @@ export interface UseFormProps<Schema extends TSchema>
|
||||
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register function for a form field.
|
||||
*/
|
||||
export type UseFormRegister<Schema extends TSchema> = <
|
||||
TFieldName extends FieldPath<Schema> = FieldPath<Schema>,
|
||||
>(
|
||||
name: TFieldName,
|
||||
options?: reactHookForm.RegisterOptions<FieldValues<Schema>, TFieldName>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
) => UseFormRegisterReturn<Schema, TFieldName>
|
||||
|
||||
/**
|
||||
* UseFormRegister return type.
|
||||
*/
|
||||
export interface UseFormRegisterReturn<
|
||||
Schema extends TSchema,
|
||||
TFieldName extends FieldPath<Schema> = FieldPath<Schema>,
|
||||
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onBlur' | 'onChange'> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
readonly onChange: <Value>(value: Value) => Promise<boolean | void>
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
readonly onBlur: <Value>(value: Value) => Promise<boolean | void>
|
||||
readonly isDisabled?: boolean
|
||||
readonly isRequired?: boolean
|
||||
readonly isInvalid?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of the useForm hook.
|
||||
* @alias reactHookForm.UseFormReturn
|
||||
*/
|
||||
export interface UseFormReturn<Schema extends TSchema>
|
||||
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> {}
|
||||
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> {
|
||||
readonly register: UseFormRegister<Schema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Form state type.
|
||||
|
@ -39,7 +39,6 @@ export function useField<
|
||||
|
||||
const { field, fieldState, formState } = reactHookForm.useController({
|
||||
name,
|
||||
control: formInstance.control,
|
||||
disabled: isDisabled,
|
||||
...(defaultValue != null ? { defaultValue } : {}),
|
||||
})
|
||||
|
@ -12,12 +12,24 @@ import invariant from 'tiny-invariant'
|
||||
import * as schemaModule from './schema'
|
||||
import type * as types from './types'
|
||||
|
||||
/**
|
||||
* Maps the value to the event object.
|
||||
*/
|
||||
function mapValueOnEvent(value: unknown) {
|
||||
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
|
||||
return value
|
||||
} else {
|
||||
return { target: { value } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that returns a form instance.
|
||||
* @param optionsOrFormInstance - Either form options or a form instance
|
||||
*
|
||||
* If form instance is passed, it will be returned as is
|
||||
* If form options are passed, a form instance will be created and returned
|
||||
* If form instance is passed, it will be returned as is.
|
||||
*
|
||||
* If form options are passed, a form instance will be created and returned.
|
||||
*
|
||||
* ***Note:*** This hook accepts either a form instance(If form is created outside)
|
||||
* or form options(and creates a form instance).
|
||||
@ -28,9 +40,6 @@ import type * as types from './types'
|
||||
*/
|
||||
export function useForm<Schema extends types.TSchema>(
|
||||
optionsOrFormInstance: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
|
||||
defaultValues?:
|
||||
| reactHookForm.DefaultValues<types.FieldValues<Schema>>
|
||||
| ((payload?: unknown) => Promise<types.FieldValues<Schema>>),
|
||||
): types.UseFormReturn<Schema> {
|
||||
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
|
||||
|
||||
@ -44,38 +53,49 @@ export function useForm<Schema extends types.TSchema>(
|
||||
`,
|
||||
)
|
||||
|
||||
const form =
|
||||
'formState' in optionsOrFormInstance ? optionsOrFormInstance : (
|
||||
(() => {
|
||||
const { schema, ...options } = optionsOrFormInstance
|
||||
if ('formState' in optionsOrFormInstance) {
|
||||
return optionsOrFormInstance
|
||||
} else {
|
||||
const { schema, ...options } = optionsOrFormInstance
|
||||
|
||||
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
|
||||
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
|
||||
|
||||
return reactHookForm.useForm<
|
||||
types.FieldValues<Schema>,
|
||||
unknown,
|
||||
types.TransformedValues<Schema>
|
||||
>({
|
||||
...options,
|
||||
resolver: zodResolver.zodResolver(computedSchema, { async: true }),
|
||||
})
|
||||
})()
|
||||
)
|
||||
const formInstance = reactHookForm.useForm<
|
||||
types.FieldValues<Schema>,
|
||||
unknown,
|
||||
types.TransformedValues<Schema>
|
||||
>({
|
||||
...options,
|
||||
resolver: zodResolver.zodResolver(computedSchema),
|
||||
})
|
||||
|
||||
const initialDefaultValues = React.useRef(defaultValues)
|
||||
const register: types.UseFormRegister<Schema> = (name, opts) => {
|
||||
const registered = formInstance.register(name, opts)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Expose default values to controlled inputs like `Selector` and `MultiSelector`.
|
||||
// Using `defaultValues` is not sufficient as the value needs to be manually set at least once.
|
||||
const defaults = initialDefaultValues.current
|
||||
if (defaults) {
|
||||
if (typeof defaults !== 'function') {
|
||||
form.reset(defaults)
|
||||
const onChange: types.UseFormRegisterReturn<Schema>['onChange'] = (value) =>
|
||||
registered.onChange(mapValueOnEvent(value))
|
||||
|
||||
const onBlur: types.UseFormRegisterReturn<Schema>['onBlur'] = (value) =>
|
||||
registered.onBlur(mapValueOnEvent(value))
|
||||
|
||||
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
|
||||
...registered,
|
||||
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
|
||||
...(registered.required != null ? { isRequired: registered.required } : {}),
|
||||
isInvalid: !!formInstance.formState.errors[name],
|
||||
onChange,
|
||||
onBlur,
|
||||
}
|
||||
}
|
||||
}, [form])
|
||||
|
||||
return form
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
...formInstance,
|
||||
control: { ...formInstance.control, register },
|
||||
register,
|
||||
} satisfies types.UseFormReturn<Schema>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -42,11 +42,17 @@ interface BaseFormProps<Schema extends components.TSchema>
|
||||
) => unknown
|
||||
readonly style?:
|
||||
| React.CSSProperties
|
||||
| ((props: FormStateRenderProps<Schema>) => React.CSSProperties)
|
||||
readonly children: React.ReactNode | ((props: FormStateRenderProps<Schema>) => React.ReactNode)
|
||||
| ((props: components.UseFormReturn<Schema>) => React.CSSProperties)
|
||||
readonly children:
|
||||
| React.ReactNode
|
||||
| ((
|
||||
props: components.UseFormReturn<Schema> & {
|
||||
readonly form: components.UseFormReturn<Schema>
|
||||
},
|
||||
) => React.ReactNode)
|
||||
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
|
||||
|
||||
readonly className?: string | ((props: FormStateRenderProps<Schema>) => string)
|
||||
readonly className?: string | ((props: components.UseFormReturn<Schema>) => string)
|
||||
|
||||
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
|
||||
readonly onSubmitSuccess?: () => Promise<void> | void
|
||||
|
@ -39,7 +39,7 @@ export const INPUT_STYLES = tv({
|
||||
variant: {
|
||||
custom: {},
|
||||
outline: {
|
||||
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[-1px] focus-within:outline-primary',
|
||||
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[0.5px] focus-within:outline-primary',
|
||||
textArea: 'border-transparent focus-within:border-transparent',
|
||||
},
|
||||
},
|
||||
|
140
app/dashboard/src/components/AriaComponents/Switch/Switch.tsx
Normal file
140
app/dashboard/src/components/AriaComponents/Switch/Switch.tsx
Normal 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>
|
||||
)
|
||||
})
|
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for Switch component.
|
||||
*/
|
||||
export * from './Switch'
|
@ -12,7 +12,7 @@ import * as text from '../Text'
|
||||
// =================
|
||||
|
||||
export const TOOLTIP_STYLES = twv.tv({
|
||||
base: 'group flex justify-center items-center text-center text-balance break-words z-50',
|
||||
base: 'group flex justify-center items-center text-center text-balance break-all z-50',
|
||||
variants: {
|
||||
variant: {
|
||||
custom: '',
|
||||
|
@ -10,6 +10,7 @@ export * from './Form'
|
||||
export * from './Inputs'
|
||||
export * from './Radio'
|
||||
export * from './Separator'
|
||||
export * from './Switch'
|
||||
export * from './Text'
|
||||
export * from './Tooltip'
|
||||
export * from './VisuallyHidden'
|
||||
|
@ -17,17 +17,19 @@ import * as billing from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import { UserSessionType } from '#/providers/AuthProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
import {
|
||||
useEnableVersionChecker,
|
||||
usePaywallDevtools,
|
||||
useSetEnableVersionChecker,
|
||||
} from '#/providers/EnsoDevtoolsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
} from './EnsoDevtoolsProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import Portal from '#/components/Portal'
|
||||
|
||||
import { Switch } from '#/components/aria'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
Popover,
|
||||
Radio,
|
||||
@ -35,246 +37,249 @@ import {
|
||||
Separator,
|
||||
Text,
|
||||
} from '#/components/AriaComponents'
|
||||
import Portal from '#/components/Portal'
|
||||
|
||||
import {
|
||||
FEATURE_FLAGS_SCHEMA,
|
||||
useFeatureFlags,
|
||||
useSetFeatureFlags,
|
||||
} from '#/providers/FeatureFlagsProvider'
|
||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||
import * as backend from '#/services/Backend'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
|
||||
|
||||
/**
|
||||
* Configuration for a paywall feature.
|
||||
*/
|
||||
export interface PaywallDevtoolsFeatureConfiguration {
|
||||
readonly isForceEnabled: boolean | null
|
||||
}
|
||||
|
||||
const PaywallDevtoolsContext = React.createContext<{
|
||||
features: Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
|
||||
}>({
|
||||
features: {
|
||||
share: { isForceEnabled: null },
|
||||
shareFull: { isForceEnabled: null },
|
||||
userGroups: { isForceEnabled: null },
|
||||
userGroupsFull: { isForceEnabled: null },
|
||||
inviteUser: { isForceEnabled: null },
|
||||
inviteUserFull: { isForceEnabled: null },
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Props for the {@link EnsoDevtools} component.
|
||||
*/
|
||||
interface EnsoDevtoolsProps extends React.PropsWithChildren {}
|
||||
|
||||
/**
|
||||
* A component that provides a UI for toggling paywall features.
|
||||
*/
|
||||
export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
const { children } = props
|
||||
|
||||
export function EnsoDevtools() {
|
||||
const { getText } = textProvider.useText()
|
||||
const { authQueryKey, session } = authProvider.useAuth()
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
const { getFeature } = billing.usePaywallFeatures()
|
||||
const { features, setFeature } = usePaywallDevtools()
|
||||
const enableVersionChecker = useEnableVersionChecker()
|
||||
const setEnableVersionChecker = useSetEnableVersionChecker()
|
||||
const { localStorage } = useLocalStorage()
|
||||
|
||||
const [features, setFeatures] = React.useState<
|
||||
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
|
||||
>({
|
||||
share: { isForceEnabled: null },
|
||||
shareFull: { isForceEnabled: null },
|
||||
userGroups: { isForceEnabled: null },
|
||||
userGroupsFull: { isForceEnabled: null },
|
||||
inviteUser: { isForceEnabled: null },
|
||||
inviteUserFull: { isForceEnabled: null },
|
||||
})
|
||||
|
||||
const { getFeature } = billing.usePaywallFeatures()
|
||||
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
|
||||
const onConfigurationChange = React.useCallback(
|
||||
(feature: billing.PaywallFeatureName, configuration: PaywallDevtoolsFeatureConfiguration) => {
|
||||
setFeatures((prev) => ({ ...prev, [feature]: configuration }))
|
||||
},
|
||||
[],
|
||||
)
|
||||
const featureFlags = useFeatureFlags()
|
||||
const setFeatureFlags = useSetFeatureFlags()
|
||||
|
||||
return (
|
||||
<PaywallDevtoolsContext.Provider value={{ features }}>
|
||||
{children}
|
||||
<Portal>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button
|
||||
icon={DevtoolsLogo}
|
||||
aria-label={getText('ensoDevtoolsButtonLabel')}
|
||||
variant="icon"
|
||||
rounded="full"
|
||||
size="hero"
|
||||
className="fixed bottom-16 right-3 z-50"
|
||||
data-ignore-click-outside
|
||||
/>
|
||||
|
||||
<Portal>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
icon={DevtoolsLogo}
|
||||
aria-label={getText('paywallDevtoolsButtonLabel')}
|
||||
variant="icon"
|
||||
rounded="full"
|
||||
size="hero"
|
||||
className="fixed bottom-16 right-3 z-50"
|
||||
data-ignore-click-outside
|
||||
/>
|
||||
<Popover>
|
||||
<Text.Heading disableLineHeightCompensation>
|
||||
{getText('ensoDevtoolsPopoverHeading')}
|
||||
</Text.Heading>
|
||||
|
||||
<Popover>
|
||||
<Text.Heading disableLineHeightCompensation>
|
||||
{getText('paywallDevtoolsPopoverHeading')}
|
||||
</Text.Heading>
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
{session?.type === UserSessionType.full && (
|
||||
<>
|
||||
<Text variant="subtitle">{getText('ensoDevtoolsPlanSelectSubtitle')}</Text>
|
||||
|
||||
{session?.type === UserSessionType.full && (
|
||||
<>
|
||||
<Text variant="subtitle">{getText('paywallDevtoolsPlanSelectSubtitle')}</Text>
|
||||
|
||||
<Form
|
||||
gap="small"
|
||||
schema={(schema) => schema.object({ plan: schema.string() })}
|
||||
defaultValues={{ plan: session.user.plan ?? 'free' }}
|
||||
>
|
||||
{({ form }) => (
|
||||
<>
|
||||
<RadioGroup
|
||||
form={form}
|
||||
name="plan"
|
||||
onChange={(value) => {
|
||||
queryClient.setQueryData(authQueryKey, {
|
||||
...session,
|
||||
user: { ...session.user, plan: value },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Radio label={getText('free')} value={'free'} />
|
||||
<Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
|
||||
</RadioGroup>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() =>
|
||||
queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => {
|
||||
form.reset()
|
||||
})
|
||||
}
|
||||
>
|
||||
{getText('reset')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
|
||||
Open setup page
|
||||
</Button>
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text variant="subtitle" className="mb-2">
|
||||
{getText('productionOnlyFeatures')}
|
||||
</Text>
|
||||
<div className="flex flex-col">
|
||||
<Switch
|
||||
className="group flex items-center gap-1"
|
||||
isSelected={enableVersionChecker ?? !IS_DEV_MODE}
|
||||
onChange={setEnableVersionChecker}
|
||||
<Form
|
||||
gap="small"
|
||||
schema={(schema) => schema.object({ plan: schema.nativeEnum(backend.Plan) })}
|
||||
defaultValues={{ plan: session.user.plan ?? backend.Plan.free }}
|
||||
>
|
||||
<div className="box-border flex h-4 w-[28px] shrink-0 cursor-default items-center rounded-full bg-primary/30 bg-clip-padding p-0.5 shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-primary/60 group-selected:bg-primary group-selected:group-pressed:bg-primary/50">
|
||||
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
|
||||
</div>
|
||||
|
||||
<Text className="flex-1">{getText('enableVersionChecker')}</Text>
|
||||
</Switch>
|
||||
|
||||
<Text variant="body" color="disabled">
|
||||
{getText('enableVersionCheckerDescription')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<Text variant="subtitle" className="mb-2">
|
||||
{getText('localStorage')}
|
||||
</Text>
|
||||
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
|
||||
<div className="flex gap-1">
|
||||
<ButtonGroup className="grow-0">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
localStorage.delete(key)
|
||||
}}
|
||||
>
|
||||
{getText('delete')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Text variant="body">
|
||||
{key
|
||||
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
|
||||
.replace(/^./, (m) => m.toUpperCase())}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<Text variant="subtitle" className="mb-2">
|
||||
{getText('paywallDevtoolsPaywallFeaturesToggles')}
|
||||
</Text>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.entries(features).map(([feature, configuration]) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const featureName = feature as billing.PaywallFeatureName
|
||||
const { label, descriptionTextId } = getFeature(featureName)
|
||||
|
||||
return (
|
||||
<div key={feature} className="flex flex-col">
|
||||
<Switch
|
||||
className="group flex items-center gap-1"
|
||||
isSelected={configuration.isForceEnabled ?? true}
|
||||
{({ form }) => (
|
||||
<>
|
||||
<RadioGroup
|
||||
name="plan"
|
||||
onChange={(value) => {
|
||||
onConfigurationChange(featureName, {
|
||||
isForceEnabled: value,
|
||||
queryClient.setQueryData(authQueryKey, {
|
||||
...session,
|
||||
user: { ...session.user, plan: value },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="box-border flex h-4 w-[28px] shrink-0 cursor-default items-center rounded-full bg-primary/30 bg-clip-padding p-0.5 shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-primary/60 group-selected:bg-primary group-selected:group-pressed:bg-primary/50">
|
||||
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
|
||||
</div>
|
||||
<Radio label={getText('free')} value={backend.Plan.free} />
|
||||
<Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
|
||||
</RadioGroup>
|
||||
|
||||
<Text className="flex-1">{getText(label)}</Text>
|
||||
</Switch>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() =>
|
||||
queryClient.invalidateQueries({ queryKey: authQueryKey }).then(() => {
|
||||
form.reset()
|
||||
})
|
||||
}
|
||||
>
|
||||
{getText('reset')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Text variant="body" color="disabled">
|
||||
{getText(descriptionTextId)}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
|
||||
Open setup page
|
||||
</Button>
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||
{getText('productionOnlyFeatures')}
|
||||
</ariaComponents.Text>
|
||||
|
||||
<ariaComponents.Form
|
||||
schema={(z) => z.object({ enableVersionChecker: z.boolean() })}
|
||||
defaultValues={{ enableVersionChecker: enableVersionChecker ?? !IS_DEV_MODE }}
|
||||
>
|
||||
{({ form }) => (
|
||||
<ariaComponents.Switch
|
||||
form={form}
|
||||
name="enableVersionChecker"
|
||||
label={getText('enableVersionChecker')}
|
||||
description={getText('enableVersionCheckerDescription')}
|
||||
onChange={(value) => {
|
||||
setEnableVersionChecker(value)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<Text variant="subtitle" className="mb-2">
|
||||
{getText('localStorage')}
|
||||
</Text>
|
||||
|
||||
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
|
||||
<div className="flex gap-1">
|
||||
<ButtonGroup className="grow-0">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
localStorage.delete(key)
|
||||
}}
|
||||
>
|
||||
{getText('delete')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Text variant="body">
|
||||
{key
|
||||
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
|
||||
.replace(/^./, (m) => m.toUpperCase())}
|
||||
</Text>
|
||||
</div>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</Portal>
|
||||
</PaywallDevtoolsContext.Provider>
|
||||
))}
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||
{getText('ensoDevtoolsFeatureFlags')}
|
||||
|
||||
<ariaComponents.Form
|
||||
gap="small"
|
||||
formOptions={{ mode: 'onChange' }}
|
||||
schema={FEATURE_FLAGS_SCHEMA}
|
||||
defaultValues={{
|
||||
enableMultitabs: featureFlags.enableMultitabs,
|
||||
enableAssetsTableBackgroundRefresh: featureFlags.enableAssetsTableBackgroundRefresh,
|
||||
assetsTableBackgroundRefreshInterval:
|
||||
featureFlags.assetsTableBackgroundRefreshInterval,
|
||||
}}
|
||||
>
|
||||
{(form) => (
|
||||
<>
|
||||
<ariaComponents.Switch
|
||||
form={form}
|
||||
name="enableMultitabs"
|
||||
label={getText('enableMultitabs')}
|
||||
description={getText('enableMultitabsDescription')}
|
||||
onChange={(value) => {
|
||||
setFeatureFlags('enableMultitabs', value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<ariaComponents.Switch
|
||||
form={form}
|
||||
name="enableAssetsTableBackgroundRefresh"
|
||||
label={getText('enableAssetsTableBackgroundRefresh')}
|
||||
description={getText('enableAssetsTableBackgroundRefreshDescription')}
|
||||
onChange={(value) => {
|
||||
setFeatureFlags('enableAssetsTableBackgroundRefresh', value)
|
||||
}}
|
||||
/>
|
||||
<ariaComponents.Input
|
||||
form={form}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
name="assetsTableBackgroundRefreshInterval"
|
||||
label={getText('enableAssetsTableBackgroundRefreshInterval')}
|
||||
description={getText('enableAssetsTableBackgroundRefreshIntervalDescription')}
|
||||
onChange={(event) => {
|
||||
setFeatureFlags(
|
||||
'assetsTableBackgroundRefreshInterval',
|
||||
event.target.valueAsNumber,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
</ariaComponents.Text>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||
{getText('ensoDevtoolsPaywallFeaturesToggles')}
|
||||
</ariaComponents.Text>
|
||||
|
||||
<ariaComponents.Form
|
||||
gap="small"
|
||||
schema={(z) =>
|
||||
z.object(Object.fromEntries(Object.keys(features).map((key) => [key, z.boolean()])))
|
||||
}
|
||||
defaultValues={Object.fromEntries(
|
||||
Object.keys(features).map((feature) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const featureName = feature as billing.PaywallFeatureName
|
||||
return [featureName, features[featureName].isForceEnabled ?? true]
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{Object.keys(features).map((feature) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const featureName = feature as billing.PaywallFeatureName
|
||||
const { label, descriptionTextId } = getFeature(featureName)
|
||||
|
||||
return (
|
||||
<ariaComponents.Switch
|
||||
key={feature}
|
||||
name={featureName}
|
||||
label={getText(label)}
|
||||
description={getText(descriptionTextId)}
|
||||
onChange={(value) => {
|
||||
setFeature(featureName, value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ariaComponents.Form>
|
||||
</Popover>
|
||||
</ariaComponents.DialogTrigger>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that provides access to the paywall devtools.
|
||||
*/
|
||||
export function usePaywallDevtools() {
|
||||
const context = React.useContext(PaywallDevtoolsContext)
|
||||
|
||||
React.useDebugValue(context)
|
||||
|
||||
return context
|
||||
}
|
||||
|
@ -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,
|
||||
}))
|
||||
}
|
@ -5,4 +5,5 @@
|
||||
*/
|
||||
|
||||
export * from './EnsoDevtools'
|
||||
export * from './EnsoDevtoolsProvider'
|
||||
export * from './ReactQueryDevtools'
|
||||
|
@ -1,64 +1,58 @@
|
||||
/** @file A table row for an arbitrary asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
import BlankIcon from '#/assets/blank.svg'
|
||||
|
||||
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import AssetContextMenu from '#/layouts/AssetContextMenu'
|
||||
import type * as assetsTable from '#/layouts/AssetsTable'
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
||||
import * as columnModule from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import AssetContextMenu from '#/layouts/AssetContextMenu'
|
||||
import type * as assetsTable from '#/layouts/AssetsTable'
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
import { isCloudCategory } from '#/layouts/CategorySwitcher/Category'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
|
||||
import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import { isCloudCategory } from '#/layouts/CategorySwitcher/Category'
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as download from '#/utilities/download'
|
||||
import { download } from '#/utilities/download'
|
||||
import * as drag from '#/utilities/drag'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as path from '#/utilities/path'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The height of the header row. */
|
||||
const HEADER_HEIGHT_PX = 34
|
||||
const HEADER_HEIGHT_PX = 40
|
||||
/** The amount of time (in milliseconds) the drag item must be held over this component
|
||||
* to make a directory row expand. */
|
||||
const DRAG_EXPAND_DELAY_MS = 500
|
||||
@ -96,12 +90,24 @@ export interface AssetRowProps
|
||||
export default function AssetRow(props: AssetRowProps) {
|
||||
const { isKeyboardSelected, isOpened, select, state, columns, onClick } = props
|
||||
const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
|
||||
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||
const {
|
||||
nodeMap,
|
||||
setAssetPanelProps,
|
||||
doToggleDirectoryExpansion,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
doDelete: doDeleteRaw,
|
||||
doRestore,
|
||||
doMove,
|
||||
category,
|
||||
} = state
|
||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
|
||||
const { visibilities, category } = state
|
||||
const { visibilities } = state
|
||||
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const driveStore = useDriveStore()
|
||||
const { user } = useFullUserSession()
|
||||
const setSelectedKeys = useSetSelectedKeys()
|
||||
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
|
||||
(visuallySelectedKeys ?? selectedKeys).has(item.key),
|
||||
@ -115,14 +121,10 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected,
|
||||
)
|
||||
const draggableProps = dragAndDropHooks.useDraggable()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const { data: users } = useBackendQuery(backend, 'listUsers', [])
|
||||
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
|
||||
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
|
||||
const rootRef = React.useRef<HTMLElement | null>(null)
|
||||
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
||||
@ -137,33 +139,14 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
readonly nodeMap: WeakRef<ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>>
|
||||
readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId>
|
||||
} | null>(null)
|
||||
const isCloud = isCloudCategory(category)
|
||||
|
||||
const outerVisibility = visibilities.get(item.key)
|
||||
const visibility =
|
||||
outerVisibility == null || outerVisibility === Visibility.visible ?
|
||||
insertionVisibility
|
||||
: outerVisibility
|
||||
const hidden = hiddenRaw || visibility === Visibility.hidden
|
||||
|
||||
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
|
||||
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
|
||||
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
|
||||
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
|
||||
const openProjectMutation = useMutation(backendMutationOptions(backend, 'openProject'))
|
||||
const closeProjectMutation = useMutation(backendMutationOptions(backend, 'closeProject'))
|
||||
const getProjectDetailsMutation = useMutation(
|
||||
backendMutationOptions(backend, 'getProjectDetails'),
|
||||
)
|
||||
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
|
||||
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
|
||||
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
|
||||
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
||||
const copyAsset = copyAssetMutation.mutateAsync
|
||||
const updateAsset = updateAssetMutation.mutateAsync
|
||||
const deleteAsset = deleteAssetMutation.mutateAsync
|
||||
const undoDeleteAsset = undoDeleteAssetMutation.mutateAsync
|
||||
const openProject = openProjectMutation.mutateAsync
|
||||
const closeProject = closeProjectMutation.mutateAsync
|
||||
const isCloud = isCloudCategory(category)
|
||||
|
||||
const { data: projectState } = useQuery({
|
||||
// This is SAFE, as `isOpened` is only true for projects.
|
||||
@ -173,6 +156,16 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
enabled: item.type === backendModule.AssetType.project,
|
||||
})
|
||||
|
||||
const toastAndLog = useToastAndLog()
|
||||
|
||||
const getProjectDetailsMutation = useMutation(
|
||||
backendMutationOptions(backend, 'getProjectDetails'),
|
||||
)
|
||||
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
|
||||
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
|
||||
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
|
||||
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
||||
|
||||
const setSelected = useEventCallback((newSelected: boolean) => {
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
|
||||
@ -204,155 +197,6 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
|
||||
React.useImperativeHandle(updateAssetRef, () => setAsset)
|
||||
|
||||
const doCopyOnBackend = React.useCallback(
|
||||
async (newParentId: backendModule.DirectoryId | null) => {
|
||||
try {
|
||||
setAsset((oldAsset) =>
|
||||
object.merge(oldAsset, {
|
||||
title: oldAsset.title + ' (copy)',
|
||||
labels: [],
|
||||
permissions: permissions.tryCreateOwnerPermission(
|
||||
`${item.path} (copy)`,
|
||||
category,
|
||||
user,
|
||||
users ?? [],
|
||||
userGroups ?? [],
|
||||
),
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
}),
|
||||
)
|
||||
newParentId ??= rootDirectoryId
|
||||
const copiedAsset = await copyAsset([
|
||||
asset.id,
|
||||
newParentId,
|
||||
asset.title,
|
||||
nodeMap.current.get(newParentId)?.item.title ?? '(unknown)',
|
||||
])
|
||||
setAsset(
|
||||
// This is SAFE, as the type of the copied asset is guaranteed to be the same
|
||||
// as the type of the original asset.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
object.merger({
|
||||
...copiedAsset.asset,
|
||||
state: { type: backendModule.ProjectState.new },
|
||||
} as Partial<backendModule.AnyAsset>),
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog('copyAssetError', error, asset.title)
|
||||
// Delete the new component representing the asset that failed to insert.
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
}
|
||||
},
|
||||
[
|
||||
setAsset,
|
||||
rootDirectoryId,
|
||||
copyAsset,
|
||||
asset.id,
|
||||
asset.title,
|
||||
nodeMap,
|
||||
item.path,
|
||||
item.key,
|
||||
category,
|
||||
user,
|
||||
users,
|
||||
userGroups,
|
||||
toastAndLog,
|
||||
dispatchAssetListEvent,
|
||||
],
|
||||
)
|
||||
|
||||
const doMove = React.useCallback(
|
||||
async (
|
||||
newParentKey: backendModule.DirectoryId | null,
|
||||
newParentId: backendModule.DirectoryId | null,
|
||||
) => {
|
||||
const nonNullNewParentKey = newParentKey ?? rootDirectoryId
|
||||
const nonNullNewParentId = newParentId ?? rootDirectoryId
|
||||
try {
|
||||
setItem((oldItem) =>
|
||||
oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId }),
|
||||
)
|
||||
const newParentPath = localBackend.extractTypeAndId(nonNullNewParentId).id
|
||||
let newId = asset.id
|
||||
if (!isCloud) {
|
||||
const oldPath = localBackend.extractTypeAndId(asset.id).id
|
||||
const newPath = path.joinPath(newParentPath, fileInfo.getFileName(oldPath))
|
||||
switch (asset.type) {
|
||||
case backendModule.AssetType.file: {
|
||||
newId = localBackend.newFileId(newPath)
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.directory: {
|
||||
newId = localBackend.newDirectoryId(newPath)
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.project:
|
||||
case backendModule.AssetType.secret:
|
||||
case backendModule.AssetType.datalink:
|
||||
case backendModule.AssetType.specialLoading:
|
||||
case backendModule.AssetType.specialEmpty: {
|
||||
// Ignored.
|
||||
// Project paths are not stored in their `id`;
|
||||
// The other asset types either do not exist on the Local backend,
|
||||
// or do not have a path.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is SAFE as the type of `newId` is not changed from its original type.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const newAsset = object.merge(asset, { id: newId as never, parentId: nonNullNewParentId })
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.move,
|
||||
newParentKey: nonNullNewParentKey,
|
||||
newParentId: nonNullNewParentId,
|
||||
key: item.key,
|
||||
item: newAsset,
|
||||
})
|
||||
setAsset(newAsset)
|
||||
await updateAsset([
|
||||
asset.id,
|
||||
{ parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
|
||||
asset.title,
|
||||
])
|
||||
} catch (error) {
|
||||
toastAndLog('moveAssetError', error, asset.title)
|
||||
setAsset(
|
||||
object.merger({
|
||||
// This is SAFE as the type of `newId` is not changed from its original type.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
id: asset.id as never,
|
||||
parentId: asset.parentId,
|
||||
projectState: asset.projectState,
|
||||
}),
|
||||
)
|
||||
setItem((oldItem) =>
|
||||
oldItem.with({ directoryKey: item.directoryKey, directoryId: item.directoryId }),
|
||||
)
|
||||
// Move the asset back to its original position.
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.move,
|
||||
newParentKey: item.directoryKey,
|
||||
newParentId: item.directoryId,
|
||||
key: item.key,
|
||||
item: asset,
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
isCloud,
|
||||
asset,
|
||||
rootDirectoryId,
|
||||
item.directoryId,
|
||||
item.directoryKey,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
updateAsset,
|
||||
setAsset,
|
||||
dispatchAssetListEvent,
|
||||
],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSoleSelected) {
|
||||
setAssetPanelProps({ backend, item, setItem })
|
||||
@ -361,66 +205,12 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}, [item, isSoleSelected, backend, setAssetPanelProps, setIsAssetPanelTemporarilyVisible])
|
||||
|
||||
const doDelete = React.useCallback(
|
||||
async (forever = false) => {
|
||||
setInsertionVisibility(Visibility.hidden)
|
||||
if (asset.type === backendModule.AssetType.directory) {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.closeFolder,
|
||||
id: asset.id,
|
||||
// This is SAFE, as this asset is already known to be a directory.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
key: item.key as backendModule.DirectoryId,
|
||||
})
|
||||
}
|
||||
try {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: item.key })
|
||||
if (
|
||||
asset.type === backendModule.AssetType.project &&
|
||||
backend.type === backendModule.BackendType.local
|
||||
) {
|
||||
if (
|
||||
asset.projectState.type !== backendModule.ProjectState.placeholder &&
|
||||
asset.projectState.type !== backendModule.ProjectState.closed
|
||||
) {
|
||||
await openProject([asset.id, null, asset.title])
|
||||
}
|
||||
try {
|
||||
await closeProject([asset.id, asset.title])
|
||||
} catch {
|
||||
// Ignored. The project was already closed.
|
||||
}
|
||||
}
|
||||
await deleteAsset([asset.id, { force: forever }, asset.title])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
toastAndLog('deleteAssetError', error, asset.title)
|
||||
}
|
||||
(forever = false) => {
|
||||
void doDeleteRaw(item.item, forever)
|
||||
},
|
||||
[
|
||||
backend,
|
||||
dispatchAssetListEvent,
|
||||
asset,
|
||||
openProject,
|
||||
closeProject,
|
||||
deleteAsset,
|
||||
item.key,
|
||||
toastAndLog,
|
||||
],
|
||||
[doDeleteRaw, item.item],
|
||||
)
|
||||
|
||||
const doRestore = React.useCallback(async () => {
|
||||
// Visually, the asset is deleted from the Trash view.
|
||||
setInsertionVisibility(Visibility.hidden)
|
||||
try {
|
||||
await undoDeleteAsset([asset.id, asset.title])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
toastAndLog('restoreAssetError', error, asset.title)
|
||||
}
|
||||
}, [dispatchAssetListEvent, asset, toastAndLog, undoDeleteAsset, item.key])
|
||||
|
||||
const doTriggerDescriptionEdit = React.useCallback(() => {
|
||||
setModal(
|
||||
<EditAssetDescriptionModal
|
||||
@ -441,18 +231,63 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
)
|
||||
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title])
|
||||
|
||||
const clearDragState = React.useCallback(() => {
|
||||
setIsDraggedOver(false)
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const onDragOver = (event: React.DragEvent<Element>) => {
|
||||
const directoryKey =
|
||||
item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
const isPayloadMatch =
|
||||
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
|
||||
const canPaste = (() => {
|
||||
if (!isPayloadMatch) {
|
||||
return true
|
||||
} else {
|
||||
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
|
||||
const parentKeys = new Map(
|
||||
Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [
|
||||
id,
|
||||
otherAsset.directoryKey,
|
||||
]),
|
||||
)
|
||||
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
|
||||
}
|
||||
return !payload.some((payloadItem) => {
|
||||
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
|
||||
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
|
||||
return !parent ? true : (
|
||||
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
|
||||
)
|
||||
})
|
||||
}
|
||||
})()
|
||||
if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') {
|
||||
setIsDraggedOver(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (state.category.type === 'trash') {
|
||||
switch (event.type) {
|
||||
case AssetEventType.deleteForever: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doDelete(true)
|
||||
doDelete(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.restore: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doRestore()
|
||||
await doRestore(item.item)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -462,22 +297,6 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
} else {
|
||||
switch (event.type) {
|
||||
// These events are handled in the specific `NameColumn` files.
|
||||
case AssetEventType.newProject:
|
||||
case AssetEventType.newFolder:
|
||||
case AssetEventType.uploadFiles:
|
||||
case AssetEventType.newDatalink:
|
||||
case AssetEventType.newSecret:
|
||||
case AssetEventType.updateFiles:
|
||||
case AssetEventType.projectClosed: {
|
||||
break
|
||||
}
|
||||
case AssetEventType.copy: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doCopyOnBackend(event.newParentId)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.cut: {
|
||||
if (event.ids.has(item.key)) {
|
||||
setInsertionVisibility(Visibility.faded)
|
||||
@ -493,25 +312,25 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
case AssetEventType.move: {
|
||||
if (event.ids.has(item.key)) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
await doMove(event.newParentKey, event.newParentId)
|
||||
await doMove(event.newParentKey, item.item)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.delete: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doDelete(false)
|
||||
doDelete(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.deleteForever: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doDelete(true)
|
||||
doDelete(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.restore: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doRestore()
|
||||
await doRestore(item.item)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -559,7 +378,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
try {
|
||||
const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title])
|
||||
const fileName = `${asset.title}.datalink`
|
||||
download.download(
|
||||
download(
|
||||
URL.createObjectURL(
|
||||
new File([JSON.stringify(value)], fileName, {
|
||||
type: 'application/json+x-enso-data-link',
|
||||
@ -712,55 +531,13 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}, item.initialAssetEvents)
|
||||
|
||||
const clearDragState = React.useCallback(() => {
|
||||
setIsDraggedOver(false)
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const onDragOver = (event: React.DragEvent<Element>) => {
|
||||
const directoryKey =
|
||||
item.item.type === backendModule.AssetType.directory ? item.key : item.directoryKey
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
const isPayloadMatch =
|
||||
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
|
||||
const canPaste = (() => {
|
||||
if (!isPayloadMatch) {
|
||||
return true
|
||||
} else {
|
||||
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
|
||||
const parentKeys = new Map(
|
||||
Array.from(nodeMap.current.entries()).map(([id, otherAsset]) => [
|
||||
id,
|
||||
otherAsset.directoryKey,
|
||||
]),
|
||||
)
|
||||
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
|
||||
}
|
||||
return !payload.some((payloadItem) => {
|
||||
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
|
||||
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
|
||||
return !parent ? true : (
|
||||
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
|
||||
)
|
||||
})
|
||||
}
|
||||
})()
|
||||
if ((isPayloadMatch && canPaste) || event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
if (item.item.type === backendModule.AssetType.directory && state.category.type !== 'trash') {
|
||||
setIsDraggedOver(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (asset.type) {
|
||||
case backendModule.AssetType.directory:
|
||||
case backendModule.AssetType.project:
|
||||
@ -784,18 +561,23 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
tabIndex={0}
|
||||
ref={(element) => {
|
||||
rootRef.current = element
|
||||
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
|
||||
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
|
||||
const scrollDown = rect.bottom - scrollRect.bottom
|
||||
if (scrollUp < 0 || scrollDown > 0) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
top: scrollUp < 0 ? scrollUp : scrollDown,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
|
||||
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
|
||||
const scrollDown = rect.bottom - scrollRect.bottom
|
||||
|
||||
if (scrollUp < 0 || scrollDown > 0) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
top: scrollUp < 0 ? scrollUp : scrollDown,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
|
||||
element.focus()
|
||||
}
|
||||
@ -819,7 +601,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
window.setTimeout(() => {
|
||||
setSelected(false)
|
||||
})
|
||||
doToggleDirectoryExpansion(item.item.id, item.key, asset.title)
|
||||
doToggleDirectoryExpansion(item.item.id, item.key)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
@ -864,7 +646,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
if (item.type === backendModule.AssetType.directory) {
|
||||
dragOverTimeoutHandle.current = window.setTimeout(() => {
|
||||
doToggleDirectoryExpansion(item.item.id, item.key, asset.title, true)
|
||||
doToggleDirectoryExpansion(item.item.id, item.key, true)
|
||||
}, DRAG_EXPAND_DELAY_MS)
|
||||
}
|
||||
// Required because `dragover` does not fire on `mouseenter`.
|
||||
@ -902,10 +684,10 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
if (state.category.type !== 'trash') {
|
||||
props.onDrop?.(event)
|
||||
clearDragState()
|
||||
const [directoryKey, directoryId, directoryTitle] =
|
||||
const [directoryKey, directoryId] =
|
||||
item.type === backendModule.AssetType.directory ?
|
||||
[item.key, item.item.id, asset.title]
|
||||
: [item.directoryKey, item.directoryId, null]
|
||||
[item.key, item.item.id]
|
||||
: [item.directoryKey, item.directoryId]
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
if (
|
||||
payload != null &&
|
||||
@ -914,7 +696,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, true)
|
||||
const ids = payload
|
||||
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
|
||||
.map((dragItem) => dragItem.key)
|
||||
@ -927,7 +709,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, true)
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: directoryKey,
|
||||
|
@ -1,21 +1,12 @@
|
||||
/** @file The icon and name of a {@link backendModule.SecretAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import DatalinkIcon from '#/assets/datalink.svg'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
|
||||
@ -25,7 +16,6 @@ import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
|
||||
// ====================
|
||||
// === DatalinkName ===
|
||||
@ -39,10 +29,8 @@ export interface DatalinkNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { backend, setIsAssetPanelTemporarilyVisible } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setIsAssetPanelTemporarilyVisible } = state
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
if (item.type !== backendModule.AssetType.datalink) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`DatalinkNameColumn` can only display Datalinks.')
|
||||
@ -50,8 +38,6 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
const asset = item.item
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
setRowState(object.merger({ isEditingName }))
|
||||
@ -61,68 +47,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
|
||||
// context menu entry should be re-added.
|
||||
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
|
||||
const doRename = async () => {
|
||||
await Promise.resolve(null)
|
||||
}
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (isEditable) {
|
||||
switch (event.type) {
|
||||
case AssetEventType.newProject:
|
||||
case AssetEventType.newFolder:
|
||||
case AssetEventType.uploadFiles:
|
||||
case AssetEventType.newSecret:
|
||||
case AssetEventType.updateFiles:
|
||||
case AssetEventType.copy:
|
||||
case AssetEventType.cut:
|
||||
case AssetEventType.cancelCut:
|
||||
case AssetEventType.move:
|
||||
case AssetEventType.delete:
|
||||
case AssetEventType.deleteForever:
|
||||
case AssetEventType.restore:
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected:
|
||||
case AssetEventType.removeSelf:
|
||||
case AssetEventType.temporarilyAddLabels:
|
||||
case AssetEventType.temporarilyRemoveLabels:
|
||||
case AssetEventType.addLabels:
|
||||
case AssetEventType.removeLabels:
|
||||
case AssetEventType.deleteLabel:
|
||||
case AssetEventType.setItem:
|
||||
case AssetEventType.projectClosed: {
|
||||
// Ignored. These events should all be unrelated to secrets.
|
||||
// `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected`
|
||||
// are handled by `AssetRow`.
|
||||
break
|
||||
}
|
||||
case AssetEventType.newDatalink: {
|
||||
if (item.key === event.placeholderId) {
|
||||
if (backend.type !== backendModule.BackendType.remote) {
|
||||
toastAndLog('localBackendDatalinkError')
|
||||
} else {
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const { id } = await createDatalinkMutation.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: asset.parentId,
|
||||
datalinkId: null,
|
||||
name: asset.title,
|
||||
value: event.value,
|
||||
},
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merger({ id }))
|
||||
} catch (error) {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
toastAndLog('createDatalinkError', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, item.initialAssetEvents)
|
||||
const doRename = () => Promise.resolve(null)
|
||||
|
||||
const handleClick = inputBindings.handler({
|
||||
editName: () => {
|
||||
@ -171,7 +96,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
onCancel={() => {
|
||||
setIsEditing(false)
|
||||
}}
|
||||
className="text grow bg-transparent font-naming"
|
||||
className="grow bg-transparent font-naming"
|
||||
>
|
||||
{asset.title}
|
||||
</EditableSpan>
|
||||
|
@ -12,11 +12,6 @@ import { useDriveStore } from '#/providers/DriveProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
@ -30,7 +25,6 @@ import * as object from '#/utilities/object'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import * as validation from '#/utilities/validation'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
|
||||
// =====================
|
||||
// === DirectoryName ===
|
||||
@ -45,21 +39,22 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
|
||||
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { backend, nodeMap } = state
|
||||
const { doToggleDirectoryExpansion } = state
|
||||
const { doToggleDirectoryExpansion, expandedDirectoryIds } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const driveStore = useDriveStore()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
|
||||
if (item.type !== backendModule.AssetType.directory) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`DirectoryNameColumn` can only display folders.')
|
||||
}
|
||||
|
||||
const asset = item.item
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
const isExpanded = item.children != null && item.isExpanded
|
||||
|
||||
const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory'))
|
||||
const isExpanded = item.children != null && expandedDirectoryIds.includes(asset.id)
|
||||
|
||||
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
@ -91,59 +86,6 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
}
|
||||
}
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (isEditable) {
|
||||
switch (event.type) {
|
||||
case AssetEventType.newProject:
|
||||
case AssetEventType.uploadFiles:
|
||||
case AssetEventType.newDatalink:
|
||||
case AssetEventType.newSecret:
|
||||
case AssetEventType.updateFiles:
|
||||
case AssetEventType.copy:
|
||||
case AssetEventType.cut:
|
||||
case AssetEventType.cancelCut:
|
||||
case AssetEventType.move:
|
||||
case AssetEventType.delete:
|
||||
case AssetEventType.deleteForever:
|
||||
case AssetEventType.restore:
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected:
|
||||
case AssetEventType.removeSelf:
|
||||
case AssetEventType.temporarilyAddLabels:
|
||||
case AssetEventType.temporarilyRemoveLabels:
|
||||
case AssetEventType.addLabels:
|
||||
case AssetEventType.removeLabels:
|
||||
case AssetEventType.deleteLabel:
|
||||
case AssetEventType.setItem:
|
||||
case AssetEventType.projectClosed: {
|
||||
// Ignored. These events should all be unrelated to directories.
|
||||
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
|
||||
// are handled by`AssetRow`.
|
||||
break
|
||||
}
|
||||
case AssetEventType.newFolder: {
|
||||
if (item.key === event.placeholderId) {
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const createdDirectory = await createDirectoryMutation.mutateAsync([
|
||||
{
|
||||
parentId: asset.parentId,
|
||||
title: asset.title,
|
||||
},
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merge(asset, createdDirectory))
|
||||
} catch (error) {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
toastAndLog('createFolderError', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, item.initialAssetEvents)
|
||||
|
||||
const handleClick = inputBindings.handler({
|
||||
editName: () => {
|
||||
setIsEditing(true)
|
||||
@ -185,7 +127,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
isExpanded && 'rotate-90',
|
||||
)}
|
||||
onPress={() => {
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
doToggleDirectoryExpansion(asset.id, item.key)
|
||||
}}
|
||||
/>
|
||||
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />
|
||||
@ -193,7 +135,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
data-testid="asset-row-name"
|
||||
editable={rowState.isEditingName}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text grow cursor-pointer bg-transparent font-naming',
|
||||
'grow cursor-pointer bg-transparent font-naming',
|
||||
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer',
|
||||
)}
|
||||
checkSubmittable={(newTitle) =>
|
||||
|
@ -9,11 +9,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
@ -26,7 +21,6 @@ import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
|
||||
// ================
|
||||
// === FileName ===
|
||||
@ -43,7 +37,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const { backend, nodeMap } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
|
||||
if (item.type !== backendModule.AssetType.file) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`FileNameColumn` can only display files.')
|
||||
@ -53,14 +47,6 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
|
||||
const updateFileMutation = useMutation(backendMutationOptions(backend, 'updateFile'))
|
||||
const uploadFileMutation = useMutation(
|
||||
backendMutationOptions(backend, 'uploadFile', {
|
||||
meta: {
|
||||
invalidates: [['assetVersions', item.item.id, item.item.title]],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
@ -89,68 +75,6 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
}
|
||||
}
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (isEditable) {
|
||||
switch (event.type) {
|
||||
case AssetEventType.newProject:
|
||||
case AssetEventType.newFolder:
|
||||
case AssetEventType.newDatalink:
|
||||
case AssetEventType.newSecret:
|
||||
case AssetEventType.copy:
|
||||
case AssetEventType.cut:
|
||||
case AssetEventType.cancelCut:
|
||||
case AssetEventType.move:
|
||||
case AssetEventType.delete:
|
||||
case AssetEventType.deleteForever:
|
||||
case AssetEventType.restore:
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected:
|
||||
case AssetEventType.removeSelf:
|
||||
case AssetEventType.temporarilyAddLabels:
|
||||
case AssetEventType.temporarilyRemoveLabels:
|
||||
case AssetEventType.addLabels:
|
||||
case AssetEventType.removeLabels:
|
||||
case AssetEventType.deleteLabel:
|
||||
case AssetEventType.setItem:
|
||||
case AssetEventType.projectClosed: {
|
||||
// Ignored. These events should all be unrelated to projects.
|
||||
// `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected`
|
||||
// are handled by `AssetRow`.
|
||||
break
|
||||
}
|
||||
case AssetEventType.updateFiles:
|
||||
case AssetEventType.uploadFiles: {
|
||||
const file = event.files.get(item.item.id)
|
||||
if (file != null) {
|
||||
const fileId = event.type !== AssetEventType.updateFiles ? null : asset.id
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const createdFile = await uploadFileMutation.mutateAsync([
|
||||
{ fileId, fileName: asset.title, parentDirectoryId: asset.parentId },
|
||||
file,
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merge(asset, { id: createdFile.id }))
|
||||
} catch (error) {
|
||||
switch (event.type) {
|
||||
case AssetEventType.uploadFiles: {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
toastAndLog(null, error)
|
||||
break
|
||||
}
|
||||
case AssetEventType.updateFiles: {
|
||||
toastAndLog(null, error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, item.initialAssetEvents)
|
||||
|
||||
const handleClick = inputBindings.handler({
|
||||
editName: () => {
|
||||
setIsEditing(true)
|
||||
@ -182,7 +106,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
<EditableSpan
|
||||
data-testid="asset-row-name"
|
||||
editable={rowState.isEditingName}
|
||||
className="text grow bg-transparent font-naming"
|
||||
className="grow bg-transparent font-naming"
|
||||
checkSubmittable={(newTitle) =>
|
||||
item.isNewTitleValid(newTitle, nodeMap.current.get(item.directoryKey)?.children)
|
||||
}
|
||||
|
@ -144,8 +144,8 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
isDisabled={isDisabled}
|
||||
isActive={!isDisabled || !isInput}
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex-1 rounded-l-full border-0 py-0',
|
||||
className={tailwindMerge.twJoin(
|
||||
'flex-1 rounded-l-full',
|
||||
permissions.PERMISSION_CLASS_NAME[permission.type],
|
||||
)}
|
||||
onPress={doShowPermissionTypeSelector}
|
||||
@ -159,7 +159,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
isDisabled={isDisabled}
|
||||
isActive={permission.docs && (!isDisabled || !isInput)}
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className={tailwindMerge.twMerge('flex-1 border-0 py-0', permissions.DOCS_CLASS_NAME)}
|
||||
className={tailwindMerge.twJoin('flex-1', permissions.DOCS_CLASS_NAME)}
|
||||
onPress={() => {
|
||||
setAction(
|
||||
permissions.toPermissionAction({
|
||||
@ -179,10 +179,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
isDisabled={isDisabled}
|
||||
isActive={permission.execute && (!isDisabled || !isInput)}
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex-1 rounded-r-full border-0 py-0',
|
||||
permissions.EXEC_CLASS_NAME,
|
||||
)}
|
||||
className={tailwindMerge.twJoin('flex-1 rounded-r-full', permissions.EXEC_CLASS_NAME)}
|
||||
onPress={() => {
|
||||
setAction(
|
||||
permissions.toPermissionAction({
|
||||
@ -206,10 +203,11 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
variant="custom"
|
||||
ref={permissionSelectorButtonRef}
|
||||
isDisabled={isDisabled}
|
||||
rounded="full"
|
||||
isActive={!isDisabled || !isInput}
|
||||
{...(isDisabled && error != null ? { title: error } : {})}
|
||||
className={tailwindMerge.twMerge(
|
||||
'w-[121px] rounded-full border-0 py-0',
|
||||
className={tailwindMerge.twJoin(
|
||||
'w-[121px]',
|
||||
permissions.PERMISSION_CLASS_NAME[permission.type],
|
||||
)}
|
||||
onPress={doShowPermissionTypeSelector}
|
||||
|
@ -1,7 +1,7 @@
|
||||
/** @file The icon and name of a {@link backendModule.ProjectAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import NetworkIcon from '#/assets/network.svg'
|
||||
|
||||
@ -15,19 +15,12 @@ import { useDriveStore } from '#/providers/DriveProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import ProjectIcon from '#/components/dashboard/ProjectIcon'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
@ -36,7 +29,6 @@ import * as permissions from '#/utilities/permissions'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import * as validation from '#/utilities/validation'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
|
||||
// ===================
|
||||
// === ProjectName ===
|
||||
@ -61,12 +53,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
isOpened,
|
||||
} = props
|
||||
const { backend, nodeMap } = state
|
||||
const client = useQueryClient()
|
||||
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const driveStore = useDriveStore()
|
||||
const doOpenProject = projectHooks.useOpenProject()
|
||||
|
||||
@ -74,6 +65,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`ProjectNameColumn` can only display projects.')
|
||||
}
|
||||
|
||||
const asset = item.item
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
const ownPermission =
|
||||
@ -96,20 +88,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
const isOtherUserUsingProject =
|
||||
isCloud && projectState.openedBy != null && projectState.openedBy !== user.email
|
||||
|
||||
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
|
||||
const updateProjectMutation = useMutation(backendMutationOptions(backend, 'updateProject'))
|
||||
const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
|
||||
const getProjectDetailsMutation = useMutation(
|
||||
backendMutationOptions(backend, 'getProjectDetails'),
|
||||
)
|
||||
const uploadFileMutation = useMutation(
|
||||
backendMutationOptions(backend, 'uploadFile', {
|
||||
meta: {
|
||||
invalidates: [['assetVersions', item.item.id, item.item.title]],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
if (isEditable) {
|
||||
@ -131,9 +110,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
{ ami: null, ideVersion: null, projectName: newTitle },
|
||||
asset.title,
|
||||
])
|
||||
await client.invalidateQueries({
|
||||
queryKey: projectHooks.createGetProjectDetailsQuery.getQueryKey(asset.id),
|
||||
})
|
||||
} catch (error) {
|
||||
toastAndLog('renameProjectError', error)
|
||||
setAsset(object.merger({ title: oldTitle }))
|
||||
@ -141,166 +117,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
}
|
||||
}
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (isEditable) {
|
||||
switch (event.type) {
|
||||
case AssetEventType.newFolder:
|
||||
case AssetEventType.newDatalink:
|
||||
case AssetEventType.newSecret:
|
||||
case AssetEventType.copy:
|
||||
case AssetEventType.cut:
|
||||
case AssetEventType.cancelCut:
|
||||
case AssetEventType.move:
|
||||
case AssetEventType.delete:
|
||||
case AssetEventType.deleteForever:
|
||||
case AssetEventType.restore:
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected:
|
||||
case AssetEventType.removeSelf:
|
||||
case AssetEventType.temporarilyAddLabels:
|
||||
case AssetEventType.temporarilyRemoveLabels:
|
||||
case AssetEventType.addLabels:
|
||||
case AssetEventType.removeLabels:
|
||||
case AssetEventType.deleteLabel:
|
||||
case AssetEventType.setItem:
|
||||
case AssetEventType.projectClosed: {
|
||||
// Ignored. Any missing project-related events should be handled by `ProjectIcon`.
|
||||
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
|
||||
// are handled by`AssetRow`.
|
||||
break
|
||||
}
|
||||
case AssetEventType.newProject: {
|
||||
// This should only run before this project gets replaced with the actual project
|
||||
// by this event handler. In both cases `key` will match, so using `key` here
|
||||
// is a mistake.
|
||||
if (asset.id === event.placeholderId) {
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const createdProject =
|
||||
event.originalId == null || event.versionId == null ?
|
||||
await createProjectMutation.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: asset.parentId,
|
||||
projectName: asset.title,
|
||||
...(event.templateId == null ?
|
||||
{}
|
||||
: { projectTemplateName: event.templateId }),
|
||||
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
|
||||
},
|
||||
])
|
||||
: await duplicateProjectMutation.mutateAsync([
|
||||
event.originalId,
|
||||
event.versionId,
|
||||
asset.title,
|
||||
])
|
||||
event.onCreated?.(createdProject)
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(
|
||||
object.merge(asset, {
|
||||
id: createdProject.projectId,
|
||||
projectState: object.merge(projectState, {
|
||||
type: backendModule.ProjectState.placeholder,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
doOpenProject({
|
||||
id: createdProject.projectId,
|
||||
type: backendType,
|
||||
parentId: asset.parentId,
|
||||
title: asset.title,
|
||||
})
|
||||
} catch (error) {
|
||||
event.onError?.()
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
toastAndLog('createProjectError', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.updateFiles:
|
||||
case AssetEventType.uploadFiles: {
|
||||
const file = event.files.get(item.key)
|
||||
if (file != null) {
|
||||
const fileId = event.type !== AssetEventType.updateFiles ? null : asset.id
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
const { extension } = backendModule.extractProjectExtension(file.name)
|
||||
const title = backendModule.stripProjectExtension(asset.title)
|
||||
setAsset(object.merge(asset, { title }))
|
||||
try {
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
const directory = localBackend.extractTypeAndId(item.directoryId).id
|
||||
let id: string
|
||||
if (
|
||||
'backendApi' in window &&
|
||||
// This non-standard property is defined in Electron.
|
||||
'path' in file &&
|
||||
typeof file.path === 'string'
|
||||
) {
|
||||
id = await window.backendApi.importProjectFromPath(file.path, directory, title)
|
||||
} else {
|
||||
const searchParams = new URLSearchParams({ directory, name: title }).toString()
|
||||
// Ideally this would use `file.stream()`, to minimize RAM
|
||||
// requirements. for uploading large projects. Unfortunately,
|
||||
// this requires HTTP/2, which is HTTPS-only, so it will not
|
||||
// work on `http://localhost`.
|
||||
const body =
|
||||
window.location.protocol === 'https:' ? file.stream() : await file.arrayBuffer()
|
||||
const path = `./api/upload-project?${searchParams}`
|
||||
const response = await fetch(path, { method: 'POST', body })
|
||||
id = await response.text()
|
||||
}
|
||||
const projectId = localBackend.newProjectId(projectManager.UUID(id))
|
||||
const listedProject = await getProjectDetailsMutation.mutateAsync([
|
||||
projectId,
|
||||
asset.parentId,
|
||||
file.name,
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merge(asset, { title: listedProject.packageName, id: projectId }))
|
||||
} else {
|
||||
const createdFile = await uploadFileMutation.mutateAsync([
|
||||
{
|
||||
fileId,
|
||||
fileName: `${title}.${extension}`,
|
||||
parentDirectoryId: asset.parentId,
|
||||
},
|
||||
file,
|
||||
])
|
||||
const project = createdFile.project
|
||||
if (project == null) {
|
||||
throw new Error('The uploaded file was not a project.')
|
||||
} else {
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(
|
||||
object.merge(asset, {
|
||||
title,
|
||||
id: project.projectId,
|
||||
projectState: project.state,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
switch (event.type) {
|
||||
case AssetEventType.uploadFiles: {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
toastAndLog('uploadProjectError', error)
|
||||
break
|
||||
}
|
||||
case AssetEventType.updateFiles: {
|
||||
toastAndLog('updateProjectError', error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, item.initialAssetEvents)
|
||||
|
||||
const handleClick = inputBindings.handler({
|
||||
editName: () => {
|
||||
setIsEditing(true)
|
||||
@ -354,7 +170,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
data-testid="asset-row-name"
|
||||
editable={rowState.isEditingName}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text grow bg-transparent font-naming',
|
||||
'grow bg-transparent font-naming',
|
||||
canExecute && !isOtherUserUsingProject && 'cursor-pointer',
|
||||
rowState.isEditingName && 'cursor-text',
|
||||
)}
|
||||
|
@ -6,17 +6,11 @@ import { useMutation } from '@tanstack/react-query'
|
||||
import KeyIcon from '#/assets/key.svg'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
@ -29,7 +23,6 @@ import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
|
||||
// =====================
|
||||
// === ConnectorName ===
|
||||
@ -42,19 +35,17 @@ export interface SecretNameColumnProps extends column.AssetColumnProps {}
|
||||
* @throws {Error} when the asset is not a {@link backendModule.SecretAsset}.
|
||||
* This should never happen. */
|
||||
export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { item, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { backend } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
if (item.type !== backendModule.AssetType.secret) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`SecretNameColumn` can only display secrets.')
|
||||
}
|
||||
const asset = item.item
|
||||
|
||||
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
|
||||
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
|
||||
|
||||
const setIsEditing = (isEditingName: boolean) => {
|
||||
@ -63,69 +54,6 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (isEditable) {
|
||||
switch (event.type) {
|
||||
case AssetEventType.newProject:
|
||||
case AssetEventType.newFolder:
|
||||
case AssetEventType.uploadFiles:
|
||||
case AssetEventType.newDatalink:
|
||||
case AssetEventType.updateFiles:
|
||||
case AssetEventType.copy:
|
||||
case AssetEventType.cut:
|
||||
case AssetEventType.cancelCut:
|
||||
case AssetEventType.move:
|
||||
case AssetEventType.delete:
|
||||
case AssetEventType.deleteForever:
|
||||
case AssetEventType.restore:
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected:
|
||||
case AssetEventType.removeSelf:
|
||||
case AssetEventType.temporarilyAddLabels:
|
||||
case AssetEventType.temporarilyRemoveLabels:
|
||||
case AssetEventType.addLabels:
|
||||
case AssetEventType.removeLabels:
|
||||
case AssetEventType.deleteLabel:
|
||||
case AssetEventType.setItem:
|
||||
case AssetEventType.projectClosed: {
|
||||
// Ignored. These events should all be unrelated to secrets.
|
||||
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
|
||||
// are handled by`AssetRow`.
|
||||
break
|
||||
}
|
||||
case AssetEventType.newSecret: {
|
||||
if (item.key === event.placeholderId) {
|
||||
if (backend.type !== backendModule.BackendType.remote) {
|
||||
toastAndLog('localBackendSecretError')
|
||||
} else {
|
||||
rowState.setVisibility(Visibility.faded)
|
||||
try {
|
||||
const id = await createSecretMutation.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: asset.parentId,
|
||||
name: asset.title,
|
||||
value: event.value,
|
||||
},
|
||||
])
|
||||
rowState.setVisibility(Visibility.visible)
|
||||
setAsset(object.merger({ id }))
|
||||
} catch (error) {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.delete,
|
||||
key: item.key,
|
||||
})
|
||||
toastAndLog('createSecretError', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, item.initialAssetEvents)
|
||||
|
||||
const handleClick = inputBindings.handler({
|
||||
editName: () => {
|
||||
setIsEditing(true)
|
||||
|
@ -70,9 +70,9 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-column-items">
|
||||
{(asset.permissions ?? []).map((other) => (
|
||||
{(asset.permissions ?? []).map((other, idx) => (
|
||||
<PermissionDisplay
|
||||
key={backendModule.getAssetPermissionId(other)}
|
||||
key={backendModule.getAssetPermissionId(other) + idx}
|
||||
action={other.permission}
|
||||
onPress={
|
||||
setQuery == null ? null : (
|
||||
|
@ -115,7 +115,7 @@ interface AssetListMoveEvent extends AssetListBaseEvent<AssetListEventType.move>
|
||||
readonly key: backend.AssetId
|
||||
readonly newParentKey: backend.DirectoryId
|
||||
readonly newParentId: backend.DirectoryId
|
||||
readonly item: backend.AnyAsset
|
||||
readonly items: backend.AnyAsset[]
|
||||
}
|
||||
|
||||
/** A signal that a file has been deleted. */
|
||||
|
@ -48,7 +48,9 @@ export function useIntersectionRatio<T>(
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
setValue(transformRef.current(entry.intersectionRatio))
|
||||
React.startTransition(() => {
|
||||
setValue(transformRef.current(entry.intersectionRatio))
|
||||
})
|
||||
}
|
||||
},
|
||||
{ root, threshold },
|
||||
@ -69,7 +71,9 @@ export function useIntersectionRatio<T>(
|
||||
const dropzoneArea = dropzoneRect.width * dropzoneRect.height
|
||||
const intersectionArea = intersectionRect.width * intersectionRect.height
|
||||
const intersectionRatio = Math.max(0, dropzoneArea / intersectionArea)
|
||||
setValue(transformRef.current(intersectionRatio))
|
||||
React.startTransition(() => {
|
||||
setValue(transformRef.current(intersectionRatio))
|
||||
})
|
||||
}
|
||||
recomputeIntersectionRatio()
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
type LaunchedProjectId,
|
||||
} from '#/providers/ProjectsProvider'
|
||||
|
||||
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type LocalBackend from '#/services/LocalBackend'
|
||||
import type RemoteBackend from '#/services/RemoteBackend'
|
||||
@ -231,11 +232,17 @@ export function useOpenProject() {
|
||||
const addLaunchedProject = useAddLaunchedProject()
|
||||
const closeAllProjects = useCloseAllProjects()
|
||||
const openProjectMutation = useOpenProjectMutation()
|
||||
|
||||
const enableMultitabs = useFeatureFlag('enableMultitabs')
|
||||
|
||||
return eventCallbacks.useEventCallback((project: LaunchedProject) => {
|
||||
// Since multiple tabs cannot be opened at the sametime, the opened projects need to be closed first.
|
||||
if (projectsStore.getState().launchedProjects.length > 0) {
|
||||
closeAllProjects()
|
||||
if (!enableMultitabs) {
|
||||
// Since multiple tabs cannot be opened at the same time, the opened projects need to be closed first.
|
||||
if (projectsStore.getState().launchedProjects.length > 0) {
|
||||
closeAllProjects()
|
||||
}
|
||||
}
|
||||
|
||||
const existingMutation = client.getMutationCache().find({
|
||||
mutationKey: ['openProject'],
|
||||
predicate: (mutation) => mutation.options.scope?.id === project.id,
|
||||
|
@ -12,11 +12,12 @@ import * as React from 'react'
|
||||
export function useSyncRef<T>(value: T): Readonly<React.MutableRefObject<T>> {
|
||||
const ref = React.useRef(value)
|
||||
|
||||
// Update the ref value whenever the provided value changes
|
||||
// Refs shall never change during the render phase, so we use `useEffect` here.
|
||||
React.useEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
/*
|
||||
Even though the react core team doesn't recommend setting ref values during the render (it might lead to deoptimizations), the reasoning behind this is:
|
||||
- We want to make useEventCallback behave the same way as const x = () => {} or useCallback but have a stable reference.
|
||||
- React components shall be idempotent by default, and we don't see violations here.
|
||||
*/
|
||||
ref.current = value
|
||||
|
||||
return ref
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -49,6 +49,7 @@ export interface AssetsTableContextMenuProps {
|
||||
newParentKey: backendModule.DirectoryId,
|
||||
newParentId: backendModule.DirectoryId,
|
||||
) => void
|
||||
readonly doDelete: (assetId: backendModule.AssetId, forever?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
/** A context menu for an `AssetsTable`, when no row is selected, or multiple rows
|
||||
@ -56,7 +57,7 @@ export interface AssetsTableContextMenuProps {
|
||||
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
|
||||
const { hidden = false, backend, category, pasteData } = props
|
||||
const { nodeMapRef, event, rootDirectoryId } = props
|
||||
const { doCopy, doCut, doPaste } = props
|
||||
const { doCopy, doCut, doPaste, doDelete } = props
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
@ -82,7 +83,10 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
const doDeleteAll = () => {
|
||||
if (isCloud) {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
|
||||
|
||||
for (const key of selectedKeys) {
|
||||
void doDelete(key, false)
|
||||
}
|
||||
} else {
|
||||
const [firstKey] = selectedKeys
|
||||
const soleAssetName =
|
||||
@ -97,7 +101,10 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
}
|
||||
doDelete={() => {
|
||||
setSelectedKeys(EMPTY_SET)
|
||||
dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
|
||||
|
||||
for (const key of selectedKeys) {
|
||||
void doDelete(key, false)
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
@ -271,6 +271,7 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
null,
|
||||
(project) => {
|
||||
setCreatedProjectId(project.projectId)
|
||||
setIsCreatingProject(false)
|
||||
},
|
||||
() => {
|
||||
setIsCreatingProject(false)
|
||||
|
@ -19,7 +19,7 @@ export interface StartModalProps {
|
||||
|
||||
/** A modal containing project templates and news. */
|
||||
export default function StartModal(props: StartModalProps) {
|
||||
const { createProject: createProjectRaw } = props
|
||||
const { createProject } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
@ -39,7 +39,7 @@ export default function StartModal(props: StartModalProps) {
|
||||
|
||||
<Samples
|
||||
createProject={(templateId, templateName) => {
|
||||
createProjectRaw(templateId, templateName)
|
||||
createProject(templateId, templateName)
|
||||
opts.close()
|
||||
}}
|
||||
/>
|
||||
|
@ -7,8 +7,8 @@ import { IS_DEV_MODE } from 'enso-common/src/detect'
|
||||
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import { useEnableVersionChecker } from '#/components/Devtools'
|
||||
import { useLocalBackend } from '#/providers/BackendProvider'
|
||||
import { useEnableVersionChecker } from '#/providers/EnsoDevtoolsProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
|
||||
import { Button, ButtonGroup, Dialog, Text } from '#/components/AriaComponents'
|
||||
|
@ -50,12 +50,13 @@ export interface DuplicateAssetsModalProps {
|
||||
readonly nonConflictingFileCount: number
|
||||
readonly nonConflictingProjectCount: number
|
||||
readonly doUploadNonConflicting: () => void
|
||||
readonly doUpdateConflicting: (toUpdate: ConflictingAsset[]) => void
|
||||
}
|
||||
|
||||
/** A modal for creating a new label. */
|
||||
export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
const { parentKey, parentId, conflictingFiles: conflictingFilesRaw } = props
|
||||
const { conflictingProjects: conflictingProjectsRaw } = props
|
||||
const { conflictingProjects: conflictingProjectsRaw, doUpdateConflicting } = props
|
||||
const { siblingFileNames: siblingFileNamesRaw } = props
|
||||
const { siblingProjectNames: siblingProjectNamesRaw } = props
|
||||
const { nonConflictingFileCount, nonConflictingProjectCount, doUploadNonConflicting } = props
|
||||
@ -125,13 +126,6 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
return title
|
||||
}
|
||||
|
||||
const doUpdate = (toUpdate: ConflictingAsset[]) => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.updateFiles,
|
||||
files: new Map(toUpdate.map((asset) => [asset.current.id, asset.file])),
|
||||
})
|
||||
}
|
||||
|
||||
const doRename = (toRename: ConflictingAsset[]) => {
|
||||
const clonedConflicts = structuredClone(toRename)
|
||||
for (const conflict of clonedConflicts) {
|
||||
@ -219,7 +213,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
<ariaComponents.Button
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
doUpdate([firstConflict])
|
||||
doUpdateConflicting([firstConflict])
|
||||
switch (firstConflict.new.type) {
|
||||
case backendModule.AssetType.file: {
|
||||
setConflictingFiles((oldConflicts) => oldConflicts.slice(1))
|
||||
@ -278,7 +272,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
onPress={() => {
|
||||
unsetModal()
|
||||
doUploadNonConflicting()
|
||||
doUpdate([...conflictingFiles, ...conflictingProjects])
|
||||
doUpdateConflicting([...conflictingFiles, ...conflictingProjects])
|
||||
}}
|
||||
>
|
||||
{count === 1 ? getText('update') : getText('updateAll')}
|
||||
|
@ -327,6 +327,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
{appRunner != null &&
|
||||
launchedProjects.map((project) => (
|
||||
<aria.TabPanel
|
||||
key={project.id}
|
||||
shouldForceMount
|
||||
id={project.id}
|
||||
className="flex min-h-0 grow [&[data-inert]]:hidden"
|
||||
|
@ -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)
|
||||
}
|
114
app/dashboard/src/providers/FeatureFlagsProvider.tsx
Normal file
114
app/dashboard/src/providers/FeatureFlagsProvider.tsx
Normal 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}</>
|
||||
}
|
@ -17,7 +17,6 @@ export interface AssetTreeNodeData
|
||||
| 'directoryId'
|
||||
| 'directoryKey'
|
||||
| 'initialAssetEvents'
|
||||
| 'isExpanded'
|
||||
| 'item'
|
||||
| 'key'
|
||||
| 'path'
|
||||
@ -52,7 +51,6 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
|
||||
* This must never change, otherwise the component's state is lost when receiving the real id
|
||||
* from the backend. */
|
||||
public readonly key: Item['id'] = item.id,
|
||||
public readonly isExpanded = false,
|
||||
public readonly createdAt = new Date(),
|
||||
) {
|
||||
this.type = item.type
|
||||
@ -72,7 +70,7 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
|
||||
directoryId: backendModule.DirectoryId,
|
||||
depth: number,
|
||||
path: string,
|
||||
initialAssetEvents: readonly assetEvent.AssetEvent[] | null,
|
||||
initialAssetEvents: readonly assetEvent.AssetEvent[] | null = null,
|
||||
key: Asset['id'] = asset.id,
|
||||
): AnyAssetTreeNode {
|
||||
return new AssetTreeNode(
|
||||
@ -115,7 +113,6 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
|
||||
update.path ?? this.path,
|
||||
update.initialAssetEvents ?? this.initialAssetEvents,
|
||||
update.key ?? this.key,
|
||||
update.isExpanded ?? this.isExpanded,
|
||||
update.createdAt ?? this.createdAt,
|
||||
).asUnion()
|
||||
}
|
||||
@ -184,7 +181,7 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
|
||||
preorderTraversal(
|
||||
preprocess: ((tree: AnyAssetTreeNode[]) => AnyAssetTreeNode[]) | null = null,
|
||||
): AnyAssetTreeNode[] {
|
||||
const children = !this.isExpanded ? [] : this.children ?? []
|
||||
const children = this.children ?? []
|
||||
return (preprocess?.(children) ?? children).flatMap((node) =>
|
||||
node.children == null ? [node] : [node, ...node.preorderTraversal(preprocess)],
|
||||
)
|
||||
|
@ -459,6 +459,14 @@
|
||||
"organizationInviteErrorSuffix": "' is inviting you.",
|
||||
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at",
|
||||
|
||||
"enableMultitabs": "Enable Multi-Tabs",
|
||||
"enableMultitabsDescription": "Open multiple projects at the same time.",
|
||||
|
||||
"enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
|
||||
"enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.",
|
||||
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
|
||||
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
|
||||
|
||||
"deleteLabelActionText": "delete the label '$0'",
|
||||
"deleteSelectedAssetActionText": "delete '$0'",
|
||||
"deleteSelectedAssetsActionText": "delete $0 selected items",
|
||||
@ -856,10 +864,11 @@
|
||||
"shareFullFeatureDescription": "Share unlimited assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.",
|
||||
"shareFullPaywallMessage": "You can only share assets with a single user group. Upgrade to share assets with multiple user groups and users.",
|
||||
|
||||
"paywallDevtoolsButtonLabel": "Open Enso Devtools",
|
||||
"paywallDevtoolsPopoverHeading": "Enso Devtools",
|
||||
"paywallDevtoolsPlanSelectSubtitle": "User Plan",
|
||||
"paywallDevtoolsPaywallFeaturesToggles": "Paywall Features",
|
||||
"ensoDevtoolsButtonLabel": "Open Enso Devtools",
|
||||
"ensoDevtoolsPopoverHeading": "Enso Devtools",
|
||||
"ensoDevtoolsPlanSelectSubtitle": "User Plan",
|
||||
"ensoDevtoolsPaywallFeaturesToggles": "Paywall Features",
|
||||
"ensoDevtoolsFeatureFlags": "Feature Flags",
|
||||
|
||||
"setupEnso": "Set up Enso",
|
||||
"termsAndConditions": "Terms and Conditions",
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "es2023"],
|
||||
"allowJs": false,
|
||||
"checkJs": false,
|
||||
"skipLibCheck": false
|
||||
|
Loading…
Reference in New Issue
Block a user