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