mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 17:11:31 +03:00
Wrap asset creation in a Promise (#11396)
- Close https://github.com/enso-org/cloud-v2/issues/1560 - Switch from "event"s to async functions so that new asset creation can be awaited # Important Notes None
This commit is contained in:
parent
d8330c9bc5
commit
f0a04b4e52
@ -834,17 +834,9 @@ function createPlaceholderId(from?: string): string {
|
||||
return id as string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a given {@link AssetId} is a placeholder id.
|
||||
*/
|
||||
/** Whether a given {@link AssetId} is a placeholder id. */
|
||||
export function isPlaceholderId(id: AssetId) {
|
||||
if (typeof id === 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('isPlaceholderId id', id, PLACEHOLDER_SIGNATURE in id)
|
||||
|
||||
return PLACEHOLDER_SIGNATURE in id
|
||||
return typeof id !== 'string' && PLACEHOLDER_SIGNATURE in id
|
||||
}
|
||||
|
||||
/**
|
||||
@ -900,7 +892,7 @@ export function createPlaceholderProjectAsset(
|
||||
title: string,
|
||||
parentId: DirectoryId,
|
||||
assetPermissions: readonly AssetPermission[],
|
||||
organization: User | null,
|
||||
user: User | null,
|
||||
path: Path | null,
|
||||
): ProjectAsset {
|
||||
return {
|
||||
@ -913,7 +905,7 @@ export function createPlaceholderProjectAsset(
|
||||
projectState: {
|
||||
type: ProjectState.new,
|
||||
volumeId: '',
|
||||
...(organization != null ? { openedBy: organization.email } : {}),
|
||||
...(user != null ? { openedBy: user.email } : {}),
|
||||
...(path != null ? { path } : {}),
|
||||
},
|
||||
extension: null,
|
||||
@ -924,6 +916,72 @@ export function createPlaceholderProjectAsset(
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a {@link DirectoryAsset} using the given values. */
|
||||
export function createPlaceholderDirectoryAsset(
|
||||
title: string,
|
||||
parentId: DirectoryId,
|
||||
assetPermissions: readonly AssetPermission[],
|
||||
): DirectoryAsset {
|
||||
return {
|
||||
type: AssetType.directory,
|
||||
id: DirectoryId(createPlaceholderId()),
|
||||
title,
|
||||
parentId,
|
||||
permissions: assetPermissions,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
labels: [],
|
||||
description: null,
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a {@link SecretAsset} using the given values. */
|
||||
export function createPlaceholderSecretAsset(
|
||||
title: string,
|
||||
parentId: DirectoryId,
|
||||
assetPermissions: readonly AssetPermission[],
|
||||
): SecretAsset {
|
||||
return {
|
||||
type: AssetType.secret,
|
||||
id: SecretId(createPlaceholderId()),
|
||||
title,
|
||||
parentId,
|
||||
permissions: assetPermissions,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
labels: [],
|
||||
description: null,
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a {@link DatalinkAsset} using the given values. */
|
||||
export function createPlaceholderDatalinkAsset(
|
||||
title: string,
|
||||
parentId: DirectoryId,
|
||||
assetPermissions: readonly AssetPermission[],
|
||||
): DatalinkAsset {
|
||||
return {
|
||||
type: AssetType.datalink,
|
||||
id: DatalinkId(createPlaceholderId()),
|
||||
title,
|
||||
parentId,
|
||||
permissions: assetPermissions,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
labels: [],
|
||||
description: null,
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link SpecialLoadingAsset}, with all irrelevant fields initialized to default
|
||||
* values.
|
||||
|
@ -12,7 +12,6 @@ import * as uniqueString from 'enso-common/src/utilities/uniqueString'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
|
||||
|
||||
// =================
|
||||
@ -170,12 +169,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
type: backend.AssetType.directory,
|
||||
id: backend.DirectoryId('directory-' + uniqueString.uniqueString()),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
},
|
||||
rest,
|
||||
)
|
||||
@ -192,12 +194,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
type: backend.ProjectState.closed,
|
||||
volumeId: '',
|
||||
},
|
||||
extension: null,
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
},
|
||||
rest,
|
||||
)
|
||||
@ -208,12 +213,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
type: backend.AssetType.file,
|
||||
id: backend.FileId('file-' + uniqueString.uniqueString()),
|
||||
projectState: null,
|
||||
extension: '',
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
},
|
||||
rest,
|
||||
)
|
||||
@ -227,12 +235,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
type: backend.AssetType.secret,
|
||||
id: backend.SecretId('secret-' + uniqueString.uniqueString()),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
},
|
||||
rest,
|
||||
)
|
||||
@ -571,23 +582,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
|
||||
const project = assetMap.get(projectId)
|
||||
|
||||
invariant(
|
||||
project,
|
||||
`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
|
||||
if (!project) {
|
||||
throw new Error(`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
|
||||
Please make sure that you've created the project before opening it.
|
||||
------------------------------------------------------------------------------------------------
|
||||
|
||||
Existing projects: ${Array.from(assetMap.values())
|
||||
.filter((asset) => asset.type === backend.AssetType.project)
|
||||
.map((asset) => asset.id)
|
||||
.join(', ')}`,
|
||||
)
|
||||
invariant(
|
||||
project.projectState,
|
||||
`Attempting to get a project that does not have a state. Usually it is a bug in the application.
|
||||
.join(', ')}`)
|
||||
}
|
||||
if (!project.projectState) {
|
||||
throw new Error(`Attempting to get a project that does not have a state. Usually it is a bug in the application.
|
||||
------------------------------------------------------------------------------------------------
|
||||
Tried to get: \n ${JSON.stringify(project, null, 2)}`,
|
||||
)
|
||||
Tried to get: \n ${JSON.stringify(project, null, 2)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId: defaultOrganizationId,
|
||||
@ -635,7 +644,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
const body: Body = request.postDataJSON()
|
||||
const parentId = body.parentDirectoryId
|
||||
// Can be any asset ID.
|
||||
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
|
||||
const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`)
|
||||
const json: backend.CopyAssetResponse = {
|
||||
asset: {
|
||||
id,
|
||||
@ -681,10 +690,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
|
||||
const project = assetMap.get(projectId)
|
||||
|
||||
invariant(
|
||||
project,
|
||||
`Tried to open a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it.`,
|
||||
)
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
`Tried to open a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (project?.projectState) {
|
||||
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened
|
||||
|
@ -95,6 +95,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
</div>
|
||||
{...errors}
|
||||
</div>,
|
||||
...errors,
|
||||
)
|
||||
} else {
|
||||
children.push(
|
||||
|
@ -9,7 +9,11 @@ import BlankIcon from '#/assets/blank.svg'
|
||||
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
|
||||
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
|
||||
import {
|
||||
useDriveStore,
|
||||
useSetSelectedKeys,
|
||||
useToggleDirectoryExpansion,
|
||||
} from '#/providers/DriveProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -36,6 +40,7 @@ import {
|
||||
backendQueryOptions,
|
||||
useAsset,
|
||||
useBackendMutationState,
|
||||
useUploadFiles,
|
||||
} from '#/hooks/backendHooks'
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
@ -274,7 +279,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
||||
const { initialAssetEvents } = props
|
||||
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
|
||||
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
|
||||
const { doToggleDirectoryExpansion } = state
|
||||
|
||||
const driveStore = useDriveStore()
|
||||
const queryClient = useQueryClient()
|
||||
@ -304,6 +308,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
||||
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
|
||||
assetRowUtils.INITIAL_ROW_STATE,
|
||||
)
|
||||
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
|
||||
|
||||
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
|
||||
const isEditingName = innerRowState.isEditingName || isNewlyCreated
|
||||
@ -343,6 +348,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
||||
|
||||
const toastAndLog = useToastAndLog()
|
||||
|
||||
const uploadFiles = useUploadFiles(backend, category)
|
||||
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
|
||||
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
||||
|
||||
@ -707,7 +713,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
||||
window.setTimeout(() => {
|
||||
setSelected(false)
|
||||
})
|
||||
doToggleDirectoryExpansion(asset.id, asset.id)
|
||||
toggleDirectoryExpansion(asset.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
@ -752,7 +758,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
||||
}
|
||||
if (asset.type === backendModule.AssetType.directory) {
|
||||
dragOverTimeoutHandle.current = window.setTimeout(() => {
|
||||
doToggleDirectoryExpansion(asset.id, asset.id, true)
|
||||
toggleDirectoryExpansion(asset.id, true)
|
||||
}, DRAG_EXPAND_DELAY_MS)
|
||||
}
|
||||
// Required because `dragover` does not fire on `mouseenter`.
|
||||
@ -800,7 +806,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
doToggleDirectoryExpansion(directoryId, directoryId, true)
|
||||
toggleDirectoryExpansion(directoryId, true)
|
||||
const ids = payload
|
||||
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
|
||||
.map((dragItem) => dragItem.key)
|
||||
@ -813,13 +819,8 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(directoryId, directoryId, true)
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: directoryId,
|
||||
parentId: directoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
toggleDirectoryExpansion(directoryId, true)
|
||||
void uploadFiles(Array.from(event.dataTransfer.files), directoryId, null)
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
@ -6,7 +6,7 @@ import FolderArrowIcon from '#/assets/folder_arrow.svg'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
|
||||
import { useDriveStore } from '#/providers/DriveProvider'
|
||||
import { useDriveStore, useToggleDirectoryExpansion } from '#/providers/DriveProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
@ -38,10 +38,11 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {
|
||||
* This should never happen.
|
||||
*/
|
||||
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const { item, selected, state, rowState, setRowState, isEditable, depth } = props
|
||||
const { backend, nodeMap, doToggleDirectoryExpansion, expandedDirectoryIds } = state
|
||||
const { item, depth, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { backend, nodeMap, expandedDirectoryIds } = state
|
||||
const { getText } = textProvider.useText()
|
||||
const driveStore = useDriveStore()
|
||||
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
|
||||
const isExpanded = expandedDirectoryIds.includes(item.id)
|
||||
|
||||
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
|
||||
@ -98,7 +99,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
isExpanded && 'rotate-90',
|
||||
)}
|
||||
onPress={() => {
|
||||
doToggleDirectoryExpansion(item.id, item.id)
|
||||
toggleDirectoryExpansion(item.id)
|
||||
}}
|
||||
/>
|
||||
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />
|
||||
|
@ -2,13 +2,7 @@
|
||||
|
||||
/** Possible types of changes to the file list. */
|
||||
enum AssetListEventType {
|
||||
newFolder = 'new-folder',
|
||||
newProject = 'new-project',
|
||||
uploadFiles = 'upload-files',
|
||||
newDatalink = 'new-datalink',
|
||||
newSecret = 'new-secret',
|
||||
duplicateProject = 'duplicate-project',
|
||||
closeFolder = 'close-folder',
|
||||
copy = 'copy',
|
||||
move = 'move',
|
||||
delete = 'delete',
|
||||
|
@ -20,13 +20,7 @@ interface AssetListBaseEvent<Type extends AssetListEventType> {
|
||||
|
||||
/** All possible events. */
|
||||
interface AssetListEvents {
|
||||
readonly newFolder: AssetListNewFolderEvent
|
||||
readonly newProject: AssetListNewProjectEvent
|
||||
readonly uploadFiles: AssetListUploadFilesEvent
|
||||
readonly newSecret: AssetListNewSecretEvent
|
||||
readonly newDatalink: AssetListNewDatalinkEvent
|
||||
readonly duplicateProject: AssetListDuplicateProjectEvent
|
||||
readonly closeFolder: AssetListCloseFolderEvent
|
||||
readonly copy: AssetListCopyEvent
|
||||
readonly move: AssetListMoveEvent
|
||||
readonly delete: AssetListDeleteEvent
|
||||
@ -45,46 +39,6 @@ type SanityCheck<
|
||||
} = AssetListEvents,
|
||||
> = [T]
|
||||
|
||||
/** A signal to create a new directory. */
|
||||
interface AssetListNewFolderEvent extends AssetListBaseEvent<AssetListEventType.newFolder> {
|
||||
readonly parentKey: backend.DirectoryId
|
||||
readonly parentId: backend.DirectoryId
|
||||
}
|
||||
|
||||
/** A signal to create a new project. */
|
||||
interface AssetListNewProjectEvent extends AssetListBaseEvent<AssetListEventType.newProject> {
|
||||
readonly parentKey: backend.DirectoryId
|
||||
readonly parentId: backend.DirectoryId
|
||||
readonly templateId: string | null
|
||||
readonly datalinkId: backend.DatalinkId | null
|
||||
readonly preferredName: string | null
|
||||
readonly onCreated?: (project: backend.CreatedProject, parentId: backend.DirectoryId) => void
|
||||
readonly onError?: () => void
|
||||
}
|
||||
|
||||
/** A signal to upload files. */
|
||||
interface AssetListUploadFilesEvent extends AssetListBaseEvent<AssetListEventType.uploadFiles> {
|
||||
readonly parentKey: backend.DirectoryId
|
||||
readonly parentId: backend.DirectoryId
|
||||
readonly files: File[]
|
||||
}
|
||||
|
||||
/** A signal to create a new secret. */
|
||||
interface AssetListNewDatalinkEvent extends AssetListBaseEvent<AssetListEventType.newDatalink> {
|
||||
readonly parentKey: backend.DirectoryId
|
||||
readonly parentId: backend.DirectoryId
|
||||
readonly name: string
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
/** A signal to create a new secret. */
|
||||
interface AssetListNewSecretEvent extends AssetListBaseEvent<AssetListEventType.newSecret> {
|
||||
readonly parentKey: backend.DirectoryId
|
||||
readonly parentId: backend.DirectoryId
|
||||
readonly name: string
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
/** A signal to duplicate a project. */
|
||||
interface AssetListDuplicateProjectEvent
|
||||
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
|
||||
@ -94,12 +48,6 @@ interface AssetListDuplicateProjectEvent
|
||||
readonly versionId: backend.S3ObjectVersionId
|
||||
}
|
||||
|
||||
/** A signal to close (collapse) a folder. */
|
||||
interface AssetListCloseFolderEvent extends AssetListBaseEvent<AssetListEventType.closeFolder> {
|
||||
readonly id: backend.DirectoryId
|
||||
readonly key: backend.DirectoryId
|
||||
}
|
||||
|
||||
/** A signal that files should be copied. */
|
||||
interface AssetListCopyEvent extends AssetListBaseEvent<AssetListEventType.copy> {
|
||||
readonly newParentKey: backend.DirectoryId
|
||||
|
@ -1,646 +0,0 @@
|
||||
/** @file Hooks for interacting with the backend. */
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
type Mutation,
|
||||
type MutationKey,
|
||||
type UseMutationOptions,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query'
|
||||
import { toast } from 'react-toastify'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import {
|
||||
backendQueryOptions as backendQueryOptionsBase,
|
||||
type BackendMethods,
|
||||
} from 'enso-common/src/backendQuery'
|
||||
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useToastAndLog, useToastAndLogWithId } from '#/hooks/toastAndLogHooks'
|
||||
import { CATEGORY_TO_FILTER_BY, type Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import {
|
||||
AssetType,
|
||||
BackendType,
|
||||
type AssetId,
|
||||
type DirectoryAsset,
|
||||
type DirectoryId,
|
||||
type User,
|
||||
type UserGroupInfo,
|
||||
} from '#/services/Backend'
|
||||
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
||||
import { usePreventNavigation } from '#/utilities/preventNavigation'
|
||||
import { toRfc3339 } from '../utilities/dateTime'
|
||||
|
||||
// The number of bytes in 1 megabyte.
|
||||
const MB_BYTES = 1_000_000
|
||||
const S3_CHUNK_SIZE_MB = Math.round(backendModule.S3_CHUNK_SIZE_BYTES / MB_BYTES)
|
||||
|
||||
// ============================
|
||||
// === DefineBackendMethods ===
|
||||
// ============================
|
||||
|
||||
/** Ensure that the given type contains only names of backend methods. */
|
||||
type DefineBackendMethods<T extends keyof Backend> = T
|
||||
|
||||
// ======================
|
||||
// === MutationMethod ===
|
||||
// ======================
|
||||
|
||||
/** Names of methods corresponding to mutations. */
|
||||
export type MutationMethod = DefineBackendMethods<
|
||||
| 'acceptInvitation'
|
||||
| 'associateTag'
|
||||
| 'changeUserGroup'
|
||||
| 'closeProject'
|
||||
| 'copyAsset'
|
||||
| 'createCheckoutSession'
|
||||
| 'createDatalink'
|
||||
| 'createDirectory'
|
||||
| 'createPermission'
|
||||
| 'createProject'
|
||||
| 'createSecret'
|
||||
| 'createTag'
|
||||
| 'createUser'
|
||||
| 'createUserGroup'
|
||||
| 'declineInvitation'
|
||||
| 'deleteAsset'
|
||||
| 'deleteDatalink'
|
||||
| 'deleteInvitation'
|
||||
| 'deleteTag'
|
||||
| 'deleteUser'
|
||||
| 'deleteUserGroup'
|
||||
| 'duplicateProject'
|
||||
| 'inviteUser'
|
||||
| 'logEvent'
|
||||
| 'openProject'
|
||||
| 'removeUser'
|
||||
| 'resendInvitation'
|
||||
| 'restoreUser'
|
||||
| 'undoDeleteAsset'
|
||||
| 'updateAsset'
|
||||
| 'updateDirectory'
|
||||
| 'updateFile'
|
||||
| 'updateOrganization'
|
||||
| 'updateProject'
|
||||
| 'updateSecret'
|
||||
| 'updateUser'
|
||||
| 'uploadFileChunk'
|
||||
| 'uploadFileEnd'
|
||||
| 'uploadFileStart'
|
||||
| 'uploadOrganizationPicture'
|
||||
| 'uploadUserPicture'
|
||||
>
|
||||
|
||||
// =======================
|
||||
// === useBackendQuery ===
|
||||
// =======================
|
||||
|
||||
export function backendQueryOptions<Method extends BackendMethods>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>
|
||||
export function backendQueryOptions<Method extends BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>> | undefined>
|
||||
/** Wrap a backend method call in a React Query. */
|
||||
export function backendQueryOptions<Method extends BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
) {
|
||||
// @ts-expect-error This call is generic over the presence or absence of `inputData`.
|
||||
return queryOptions<Awaited<ReturnType<Backend[Method]>>>({
|
||||
...options,
|
||||
...backendQueryOptionsBase(backend, method, args, options?.queryKey),
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
queryFn: () => (backend?.[method] as any)?.(...args),
|
||||
})
|
||||
}
|
||||
|
||||
export function useBackendQuery<Method extends BackendMethods>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
|
||||
export function useBackendQuery<Method extends BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): UseQueryResult<Awaited<ReturnType<Backend[Method]>> | undefined>
|
||||
/** Wrap a backend method call in a React Query. */
|
||||
export function useBackendQuery<Method extends BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
) {
|
||||
return useQuery(backendQueryOptions(backend, method, args, options))
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === useBackendMutation ===
|
||||
// ==========================
|
||||
|
||||
const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries')
|
||||
const INVALIDATION_MAP: Partial<
|
||||
Record<MutationMethod, readonly (BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
|
||||
> = {
|
||||
createUser: ['usersMe'],
|
||||
updateUser: ['usersMe'],
|
||||
deleteUser: ['usersMe'],
|
||||
restoreUser: ['usersMe'],
|
||||
uploadUserPicture: ['usersMe'],
|
||||
updateOrganization: ['getOrganization'],
|
||||
uploadOrganizationPicture: ['getOrganization'],
|
||||
createUserGroup: ['listUserGroups'],
|
||||
deleteUserGroup: ['listUserGroups'],
|
||||
changeUserGroup: ['listUsers'],
|
||||
createTag: ['listTags'],
|
||||
deleteTag: ['listTags'],
|
||||
associateTag: ['listDirectory'],
|
||||
acceptInvitation: [INVALIDATE_ALL_QUERIES],
|
||||
declineInvitation: ['usersMe'],
|
||||
createProject: ['listDirectory'],
|
||||
duplicateProject: ['listDirectory'],
|
||||
createDirectory: ['listDirectory'],
|
||||
createSecret: ['listDirectory'],
|
||||
updateSecret: ['listDirectory'],
|
||||
createDatalink: ['listDirectory', 'getDatalink'],
|
||||
uploadFileEnd: ['listDirectory'],
|
||||
copyAsset: ['listDirectory', 'listAssetVersions'],
|
||||
deleteAsset: ['listDirectory', 'listAssetVersions'],
|
||||
undoDeleteAsset: ['listDirectory'],
|
||||
updateAsset: ['listDirectory', 'listAssetVersions'],
|
||||
closeProject: ['listDirectory', 'listAssetVersions'],
|
||||
updateDirectory: ['listDirectory'],
|
||||
}
|
||||
|
||||
/** The type of the corresponding mutation for the given backend method. */
|
||||
export type BackendMutation<Method extends MutationMethod> = Mutation<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>
|
||||
|
||||
export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>
|
||||
export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>> | undefined,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>
|
||||
/** Wrap a backend method call in a React Query Mutation. */
|
||||
export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>> {
|
||||
return {
|
||||
...options,
|
||||
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])],
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
mutationFn: (args) => (backend?.[method] as any)?.(...args),
|
||||
networkMode: backend?.type === BackendType.local ? 'always' : 'online',
|
||||
meta: {
|
||||
invalidates: [
|
||||
...(options?.meta?.invalidates ?? []),
|
||||
...(INVALIDATION_MAP[method]?.map((queryMethod) =>
|
||||
queryMethod === INVALIDATE_ALL_QUERIES ? [backend?.type] : [backend?.type, queryMethod],
|
||||
) ?? []),
|
||||
],
|
||||
awaitInvalidates: options?.meta?.awaitInvalidates ?? true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================
|
||||
// === useListUserGroupsWithUsers ===
|
||||
// ==================================
|
||||
|
||||
/** A user group, as well as the users that are a part of the user group. */
|
||||
export interface UserGroupInfoWithUsers extends UserGroupInfo {
|
||||
readonly users: readonly User[]
|
||||
}
|
||||
|
||||
/** A list of user groups, taking into account optimistic state. */
|
||||
export function useListUserGroupsWithUsers(
|
||||
backend: Backend,
|
||||
): readonly UserGroupInfoWithUsers[] | null {
|
||||
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
|
||||
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
|
||||
return useMemo(() => {
|
||||
if (listUserGroupsQuery.data == null || listUsersQuery.data == null) {
|
||||
return null
|
||||
} else {
|
||||
const result = listUserGroupsQuery.data.map((userGroup) => {
|
||||
const usersInGroup: readonly User[] = listUsersQuery.data.filter((user) =>
|
||||
user.userGroups?.includes(userGroup.id),
|
||||
)
|
||||
return { ...userGroup, users: usersInGroup }
|
||||
})
|
||||
return result
|
||||
}
|
||||
}, [listUserGroupsQuery.data, listUsersQuery.data])
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface ListDirectoryQueryOptions {
|
||||
readonly backend: Backend
|
||||
readonly parentId: DirectoryId
|
||||
readonly category: Category
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a query options object to fetch the children of a directory.
|
||||
*/
|
||||
export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) {
|
||||
const { backend, parentId, category } = options
|
||||
|
||||
return queryOptions({
|
||||
queryKey: [
|
||||
backend.type,
|
||||
'listDirectory',
|
||||
parentId,
|
||||
{
|
||||
labels: null,
|
||||
filterBy: CATEGORY_TO_FILTER_BY[category.type],
|
||||
recentProjects: category.type === 'recent',
|
||||
},
|
||||
] as const,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await backend.listDirectory(
|
||||
{
|
||||
parentId,
|
||||
filterBy: CATEGORY_TO_FILTER_BY[category.type],
|
||||
labels: null,
|
||||
recentProjects: category.type === 'recent',
|
||||
},
|
||||
parentId,
|
||||
)
|
||||
} catch {
|
||||
throw Object.assign(new Error(), { parentId })
|
||||
}
|
||||
},
|
||||
|
||||
meta: { persist: false },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload progress for {@link useUploadFileMutation}.
|
||||
*/
|
||||
export interface UploadFileMutationProgress {
|
||||
/**
|
||||
* Whether this is the first progress update.
|
||||
* Useful to determine whether to create a new toast or to update an existing toast.
|
||||
*/
|
||||
readonly event: 'begin' | 'chunk' | 'end'
|
||||
readonly sentMb: number
|
||||
readonly totalMb: number
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface UseAssetOptions extends ListDirectoryQueryOptions {
|
||||
readonly assetId: AssetId
|
||||
}
|
||||
|
||||
/** Data for a specific asset. */
|
||||
export function useAsset(options: UseAssetOptions) {
|
||||
const { parentId, assetId } = options
|
||||
|
||||
const { data: asset } = useQuery({
|
||||
...listDirectoryQueryOptions(options),
|
||||
select: (data) => data.find((child) => child.id === assetId),
|
||||
})
|
||||
|
||||
if (asset) {
|
||||
return asset
|
||||
}
|
||||
|
||||
const shared = {
|
||||
parentId,
|
||||
projectState: null,
|
||||
extension: null,
|
||||
description: '',
|
||||
modifiedAt: toRfc3339(new Date()),
|
||||
permissions: [],
|
||||
labels: [],
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
switch (true) {
|
||||
case assetId === USERS_DIRECTORY_ID: {
|
||||
return {
|
||||
...shared,
|
||||
id: assetId,
|
||||
title: 'Users',
|
||||
type: AssetType.directory,
|
||||
} satisfies DirectoryAsset
|
||||
}
|
||||
case assetId === TEAMS_DIRECTORY_ID: {
|
||||
return {
|
||||
...shared,
|
||||
id: assetId,
|
||||
title: 'Teams',
|
||||
type: AssetType.directory,
|
||||
} satisfies DirectoryAsset
|
||||
}
|
||||
case backendModule.isLoadingAssetId(assetId): {
|
||||
return {
|
||||
...shared,
|
||||
id: assetId,
|
||||
title: '',
|
||||
type: AssetType.specialLoading,
|
||||
} satisfies backendModule.SpecialLoadingAsset
|
||||
}
|
||||
case backendModule.isEmptyAssetId(assetId): {
|
||||
return {
|
||||
...shared,
|
||||
id: assetId,
|
||||
title: '',
|
||||
type: AssetType.specialEmpty,
|
||||
} satisfies backendModule.SpecialEmptyAsset
|
||||
}
|
||||
case backendModule.isErrorAssetId(assetId): {
|
||||
return {
|
||||
...shared,
|
||||
id: assetId,
|
||||
title: '',
|
||||
type: AssetType.specialError,
|
||||
} satisfies backendModule.SpecialErrorAsset
|
||||
}
|
||||
default: {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Data for a specific asset. */
|
||||
export function useAssetStrict(options: UseAssetOptions) {
|
||||
const asset = useAsset(options)
|
||||
|
||||
invariant(
|
||||
asset,
|
||||
`Expected asset to be defined, but got undefined, Asset ID: ${JSON.stringify(options.assetId)}`,
|
||||
)
|
||||
|
||||
return asset
|
||||
}
|
||||
|
||||
/** Return matching in-flight mutations */
|
||||
export function useBackendMutationState<Method extends MutationMethod, Result>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
options: {
|
||||
mutationKey?: MutationKey
|
||||
predicate?: (mutation: BackendMutation<Method>) => boolean
|
||||
select?: (mutation: BackendMutation<Method>) => Result
|
||||
} = {},
|
||||
) {
|
||||
const { mutationKey, predicate, select } = options
|
||||
return useMutationState({
|
||||
filters: {
|
||||
...backendMutationOptions(backend, method, mutationKey ? { mutationKey } : {}),
|
||||
predicate: (mutation: BackendMutation<Method>) =>
|
||||
mutation.state.status === 'pending' && (predicate?.(mutation) ?? true),
|
||||
},
|
||||
// This is UNSAFE when the `Result` parameter is explicitly specified in the
|
||||
// generic parameter list.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
select: select as (mutation: Mutation<unknown, Error, unknown, unknown>) => Result,
|
||||
})
|
||||
}
|
||||
|
||||
/** Options for {@link useUploadFileMutation}. */
|
||||
export interface UploadFileMutationOptions {
|
||||
/**
|
||||
* Defaults to 3.
|
||||
* Controls the default value of {@link UploadFileMutationOptions['chunkRetries']}
|
||||
* and {@link UploadFileMutationOptions['endRetries']}.
|
||||
*/
|
||||
readonly retries?: number
|
||||
/** Defaults to {@link UploadFileMutationOptions['retries']}. */
|
||||
readonly chunkRetries?: number
|
||||
/** Defaults to {@link UploadFileMutationOptions['retries']}. */
|
||||
readonly endRetries?: number
|
||||
/** Called for all progress updates (`onBegin`, `onChunkSuccess` and `onSuccess`). */
|
||||
readonly onProgress?: (progress: UploadFileMutationProgress) => void
|
||||
/** Called before any mutations are sent. */
|
||||
readonly onBegin?: (progress: UploadFileMutationProgress) => void
|
||||
/** Called after each successful chunk upload mutation. */
|
||||
readonly onChunkSuccess?: (progress: UploadFileMutationProgress) => void
|
||||
/** Called after the entire mutation succeeds. */
|
||||
readonly onSuccess?: (progress: UploadFileMutationProgress) => void
|
||||
/** Called after any mutations fail. */
|
||||
readonly onError?: (error: unknown) => void
|
||||
/** Called after `onSuccess` or `onError`, depending on whether the mutation succeeded. */
|
||||
readonly onSettled?: (progress: UploadFileMutationProgress | null, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Call "upload file" mutations for a file.
|
||||
* Always uses multipart upload for Cloud backend.
|
||||
* Shows toasts to update progress.
|
||||
*/
|
||||
export function useUploadFileWithToastMutation(
|
||||
backend: Backend,
|
||||
options: UploadFileMutationOptions = {},
|
||||
) {
|
||||
const toastId = useId()
|
||||
const { getText } = useText()
|
||||
const toastAndLog = useToastAndLogWithId()
|
||||
const { onBegin, onChunkSuccess, onSuccess, onError } = options
|
||||
|
||||
const mutation = useUploadFileMutation(backend, {
|
||||
...options,
|
||||
onBegin: (progress) => {
|
||||
onBegin?.(progress)
|
||||
const { sentMb, totalMb } = progress
|
||||
toast.loading(getText('uploadLargeFileStatus', sentMb, totalMb), {
|
||||
toastId,
|
||||
position: 'bottom-right',
|
||||
})
|
||||
},
|
||||
onChunkSuccess: (progress) => {
|
||||
onChunkSuccess?.(progress)
|
||||
const { sentMb, totalMb } = progress
|
||||
const text = getText('uploadLargeFileStatus', sentMb, totalMb)
|
||||
toast.update(toastId, { render: text })
|
||||
},
|
||||
onSuccess: (progress) => {
|
||||
onSuccess?.(progress)
|
||||
toast.update(toastId, {
|
||||
type: 'success',
|
||||
render: getText('uploadLargeFileSuccess'),
|
||||
isLoading: false,
|
||||
autoClose: null,
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
toastAndLog(toastId, 'uploadLargeFileError', error)
|
||||
},
|
||||
})
|
||||
|
||||
usePreventNavigation({ message: getText('anUploadIsInProgress'), isEnabled: mutation.isPending })
|
||||
|
||||
return mutation
|
||||
}
|
||||
|
||||
/**
|
||||
* Call "upload file" mutations for a file.
|
||||
* Always uses multipart upload for Cloud backend.
|
||||
*/
|
||||
export function useUploadFileMutation(backend: Backend, options: UploadFileMutationOptions = {}) {
|
||||
const toastAndLog = useToastAndLog()
|
||||
const {
|
||||
retries = 3,
|
||||
chunkRetries = retries,
|
||||
endRetries = retries,
|
||||
onError = (error) => {
|
||||
toastAndLog('uploadLargeFileError', error)
|
||||
},
|
||||
} = options
|
||||
const uploadFileStartMutation = useMutation(backendMutationOptions(backend, 'uploadFileStart'))
|
||||
const uploadFileChunkMutation = useMutation(
|
||||
backendMutationOptions(backend, 'uploadFileChunk', { retry: chunkRetries }),
|
||||
)
|
||||
const uploadFileEndMutation = useMutation(
|
||||
backendMutationOptions(backend, 'uploadFileEnd', { retry: endRetries }),
|
||||
)
|
||||
const [variables, setVariables] =
|
||||
useState<[params: backendModule.UploadFileRequestParams, file: File]>()
|
||||
const [sentMb, setSentMb] = useState(0)
|
||||
const [totalMb, setTotalMb] = useState(0)
|
||||
const mutateAsync = useEventCallback(
|
||||
async (body: backendModule.UploadFileRequestParams, file: File) => {
|
||||
setVariables([body, file])
|
||||
const fileSizeMb = Math.ceil(file.size / MB_BYTES)
|
||||
options.onBegin?.({ event: 'begin', sentMb: 0, totalMb: fileSizeMb })
|
||||
setSentMb(0)
|
||||
setTotalMb(fileSizeMb)
|
||||
try {
|
||||
const { sourcePath, uploadId, presignedUrls } = await uploadFileStartMutation.mutateAsync([
|
||||
body,
|
||||
file,
|
||||
])
|
||||
const parts: backendModule.S3MultipartPart[] = []
|
||||
for (const [url, i] of Array.from(
|
||||
presignedUrls,
|
||||
(presignedUrl, index) => [presignedUrl, index] as const,
|
||||
)) {
|
||||
parts.push(await uploadFileChunkMutation.mutateAsync([url, file, i]))
|
||||
const newSentMb = Math.min((i + 1) * S3_CHUNK_SIZE_MB, fileSizeMb)
|
||||
setSentMb(newSentMb)
|
||||
options.onChunkSuccess?.({
|
||||
event: 'chunk',
|
||||
sentMb: newSentMb,
|
||||
totalMb: fileSizeMb,
|
||||
})
|
||||
}
|
||||
const result = await uploadFileEndMutation.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: body.parentDirectoryId,
|
||||
parts,
|
||||
sourcePath: sourcePath,
|
||||
uploadId: uploadId,
|
||||
assetId: body.fileId,
|
||||
fileName: body.fileName,
|
||||
},
|
||||
])
|
||||
setSentMb(fileSizeMb)
|
||||
const progress: UploadFileMutationProgress = {
|
||||
event: 'end',
|
||||
sentMb: fileSizeMb,
|
||||
totalMb: fileSizeMb,
|
||||
}
|
||||
options.onSuccess?.(progress)
|
||||
options.onSettled?.(progress, null)
|
||||
return result
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
options.onSettled?.(null, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
const mutate = useEventCallback((params: backendModule.UploadFileRequestParams, file: File) => {
|
||||
void mutateAsync(params, file)
|
||||
})
|
||||
|
||||
return {
|
||||
sentMb,
|
||||
totalMb,
|
||||
variables,
|
||||
mutate,
|
||||
mutateAsync,
|
||||
context: uploadFileEndMutation.context,
|
||||
data: uploadFileEndMutation.data,
|
||||
failureCount:
|
||||
uploadFileEndMutation.failureCount +
|
||||
uploadFileChunkMutation.failureCount +
|
||||
uploadFileStartMutation.failureCount,
|
||||
failureReason:
|
||||
uploadFileEndMutation.failureReason ??
|
||||
uploadFileChunkMutation.failureReason ??
|
||||
uploadFileStartMutation.failureReason,
|
||||
isError:
|
||||
uploadFileStartMutation.isError ||
|
||||
uploadFileChunkMutation.isError ||
|
||||
uploadFileEndMutation.isError,
|
||||
error:
|
||||
uploadFileEndMutation.error ?? uploadFileChunkMutation.error ?? uploadFileStartMutation.error,
|
||||
isPaused:
|
||||
uploadFileStartMutation.isPaused ||
|
||||
uploadFileChunkMutation.isPaused ||
|
||||
uploadFileEndMutation.isPaused,
|
||||
isPending:
|
||||
uploadFileStartMutation.isPending ||
|
||||
uploadFileChunkMutation.isPending ||
|
||||
uploadFileEndMutation.isPending,
|
||||
isSuccess: uploadFileEndMutation.isSuccess,
|
||||
}
|
||||
}
|
1276
app/gui/src/dashboard/hooks/backendHooks.tsx
Normal file
1276
app/gui/src/dashboard/hooks/backendHooks.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackendModule from '#/services/LocalBackend'
|
||||
|
||||
import { useUploadFileWithToastMutation } from '#/hooks/backendHooks'
|
||||
import { useNewProject, useUploadFileWithToastMutation } from '#/hooks/backendHooks'
|
||||
import {
|
||||
usePasteData,
|
||||
useSetAssetPanelProps,
|
||||
@ -105,6 +105,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
const newProject = useNewProject(backend, category)
|
||||
|
||||
const systemApi = window.systemApi
|
||||
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
||||
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
||||
@ -225,14 +227,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
hidden={hidden}
|
||||
action="useInNewProject"
|
||||
doAction={() => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newProject,
|
||||
parentId: asset.parentId,
|
||||
parentKey: asset.parentId,
|
||||
templateId: null,
|
||||
datalinkId: asset.id,
|
||||
preferredName: asset.title,
|
||||
})
|
||||
void newProject(
|
||||
{ templateName: asset.title, datalinkId: asset.id },
|
||||
asset.parentId,
|
||||
path,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -513,9 +512,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
<GlobalContextMenu
|
||||
hidden={hidden}
|
||||
backend={backend}
|
||||
category={category}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
directoryKey={asset.id}
|
||||
directoryId={asset.id}
|
||||
path={path}
|
||||
doPaste={doPaste}
|
||||
/>
|
||||
)}
|
||||
|
@ -16,15 +16,8 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
|
||||
import {
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from '@tanstack/react-query'
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'react-toastify'
|
||||
import invariant from 'tiny-invariant'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
|
||||
@ -61,7 +54,8 @@ import {
|
||||
backendMutationOptions,
|
||||
listDirectoryQueryOptions,
|
||||
useBackendQuery,
|
||||
useUploadFileWithToastMutation,
|
||||
useRootDirectoryId,
|
||||
useUploadFiles,
|
||||
} from '#/hooks/backendHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useIntersectionRatio } from '#/hooks/intersectionHooks'
|
||||
@ -76,7 +70,6 @@ import {
|
||||
type Category,
|
||||
} from '#/layouts/CategorySwitcher/Category'
|
||||
import DragModal from '#/modals/DragModal'
|
||||
import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal'
|
||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import {
|
||||
@ -86,6 +79,7 @@ import {
|
||||
} from '#/providers/BackendProvider'
|
||||
import {
|
||||
useDriveStore,
|
||||
useExpandedDirectoryIds,
|
||||
useResetAssetPanelProps,
|
||||
useSetAssetPanelProps,
|
||||
useSetCanCreateAssets,
|
||||
@ -97,10 +91,11 @@ import {
|
||||
useSetSuggestions,
|
||||
useSetTargetDirectory,
|
||||
useSetVisuallySelectedKeys,
|
||||
useToggleDirectoryExpansion,
|
||||
} from '#/providers/DriveProvider'
|
||||
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
|
||||
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||
import { useLocalStorage, useLocalStorageState } from '#/providers/LocalStorageProvider'
|
||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { useNavigator2D } from '#/providers/Navigator2DProvider'
|
||||
import { useLaunchedProjects } from '#/providers/ProjectsProvider'
|
||||
@ -108,39 +103,24 @@ import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import {
|
||||
assetIsDirectory,
|
||||
assetIsFile,
|
||||
assetIsProject,
|
||||
AssetType,
|
||||
BackendType,
|
||||
createPlaceholderAssetId,
|
||||
createPlaceholderFileAsset,
|
||||
createPlaceholderProjectAsset,
|
||||
createRootDirectoryAsset,
|
||||
createSpecialEmptyAsset,
|
||||
createSpecialErrorAsset,
|
||||
createSpecialLoadingAsset,
|
||||
DatalinkId,
|
||||
DirectoryId,
|
||||
escapeSpecialCharacters,
|
||||
extractProjectExtension,
|
||||
fileIsNotProject,
|
||||
fileIsProject,
|
||||
getAssetPermissionName,
|
||||
Path,
|
||||
Plan,
|
||||
ProjectId,
|
||||
ProjectState,
|
||||
SecretId,
|
||||
stripProjectExtension,
|
||||
type AnyAsset,
|
||||
type AssetId,
|
||||
type DatalinkAsset,
|
||||
type DirectoryAsset,
|
||||
type DirectoryId,
|
||||
type LabelName,
|
||||
type ProjectAsset,
|
||||
type SecretAsset,
|
||||
} from '#/services/Backend'
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
|
||||
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
||||
import type { AssetQueryKey } from '#/utilities/AssetQuery'
|
||||
@ -302,11 +282,6 @@ export interface AssetsTableState {
|
||||
readonly setQuery: Dispatch<SetStateAction<AssetQuery>>
|
||||
readonly nodeMap: Readonly<MutableRefObject<ReadonlyMap<AssetId, AnyAssetTreeNode>>>
|
||||
readonly hideColumn: (column: Column) => void
|
||||
readonly doToggleDirectoryExpansion: (
|
||||
directoryId: DirectoryId,
|
||||
key: DirectoryId,
|
||||
override?: boolean,
|
||||
) => void
|
||||
readonly doCopy: () => void
|
||||
readonly doCut: () => void
|
||||
readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => void
|
||||
@ -380,24 +355,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
const { data: users } = useBackendQuery(backend, 'listUsers', [])
|
||||
const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', [])
|
||||
const organizationQuery = useSuspenseQuery({
|
||||
queryKey: [backend.type, 'getOrganization'],
|
||||
queryFn: () => backend.getOrganization(),
|
||||
})
|
||||
|
||||
const organization = organizationQuery.data
|
||||
|
||||
const nameOfProjectToImmediatelyOpenRef = useRef(initialProjectName)
|
||||
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
|
||||
const rootDirectoryId = useMemo(() => {
|
||||
const localRootPath = localRootDirectory != null ? Path(localRootDirectory) : null
|
||||
const id =
|
||||
'homeDirectoryId' in category ?
|
||||
category.homeDirectoryId
|
||||
: backend.rootDirectoryId(user, organization, localRootPath)
|
||||
invariant(id, 'Missing root directory')
|
||||
return id
|
||||
}, [category, backend, user, organization, localRootDirectory])
|
||||
const rootDirectoryId = useRootDirectoryId(backend, category)
|
||||
|
||||
const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
|
||||
|
||||
@ -405,16 +365,12 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const assetsTableBackgroundRefreshInterval = useFeatureFlag(
|
||||
'assetsTableBackgroundRefreshInterval',
|
||||
)
|
||||
/**
|
||||
* The expanded directories in the asset tree.
|
||||
* We don't include the root directory as it might change when a user switches
|
||||
* between items in sidebar and we don't want to reset the expanded state using useEffect.
|
||||
*/
|
||||
const [privateExpandedDirectoryIds, setExpandedDirectoryIds] = useState<DirectoryId[]>(() => [])
|
||||
const expandedDirectoryIdsRaw = useExpandedDirectoryIds()
|
||||
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
|
||||
|
||||
const expandedDirectoryIds = useMemo(
|
||||
() => [rootDirectoryId].concat(privateExpandedDirectoryIds),
|
||||
[privateExpandedDirectoryIds, rootDirectoryId],
|
||||
() => [rootDirectoryId].concat(expandedDirectoryIdsRaw),
|
||||
[expandedDirectoryIdsRaw, rootDirectoryId],
|
||||
)
|
||||
|
||||
const expandedDirectoryIdsSet = useMemo(
|
||||
@ -422,13 +378,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
[expandedDirectoryIds],
|
||||
)
|
||||
|
||||
const createProjectMutation = useMutation(backendMutationOptions(backend, 'createProject'))
|
||||
const uploadFiles = useUploadFiles(backend, category)
|
||||
const duplicateProjectMutation = useMutation(backendMutationOptions(backend, 'duplicateProject'))
|
||||
const createDirectoryMutation = useMutation(backendMutationOptions(backend, 'createDirectory'))
|
||||
const createSecretMutation = useMutation(backendMutationOptions(backend, 'createSecret'))
|
||||
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
|
||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
||||
const uploadFileMutation = useUploadFileWithToastMutation(backend)
|
||||
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
|
||||
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
|
||||
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
|
||||
@ -1193,28 +1145,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
[driveStore, resetAssetPanelProps, setIsAssetPanelTemporarilyVisible],
|
||||
)
|
||||
|
||||
const doToggleDirectoryExpansion = useEventCallback(
|
||||
(directoryId: DirectoryId, _key: DirectoryId, override?: boolean) => {
|
||||
const isExpanded = expandedDirectoryIdsSet.has(directoryId)
|
||||
const shouldExpand = override ?? !isExpanded
|
||||
|
||||
if (shouldExpand !== isExpanded) {
|
||||
startTransition(() => {
|
||||
if (shouldExpand) {
|
||||
setExpandedDirectoryIds((currentExpandedDirectoryIds) => [
|
||||
...currentExpandedDirectoryIds,
|
||||
directoryId,
|
||||
])
|
||||
} else {
|
||||
setExpandedDirectoryIds((currentExpandedDirectoryIds) =>
|
||||
currentExpandedDirectoryIds.filter((id) => id !== directoryId),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const doCopyOnBackend = useEventCallback(
|
||||
async (newParentId: DirectoryId | null, asset: AnyAsset) => {
|
||||
try {
|
||||
@ -1252,11 +1182,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
resetAssetPanelProps()
|
||||
}
|
||||
if (asset.type === AssetType.directory) {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.closeFolder,
|
||||
id: asset.id,
|
||||
key: asset.id,
|
||||
})
|
||||
toggleDirectoryExpansion(asset.id, false)
|
||||
}
|
||||
try {
|
||||
if (asset.type === AssetType.project && backend.type === BackendType.local) {
|
||||
@ -1326,7 +1252,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
case AssetType.directory: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(item.item.id, item.key)
|
||||
toggleDirectoryExpansion(item.item.id)
|
||||
break
|
||||
}
|
||||
case AssetType.project: {
|
||||
@ -1378,7 +1304,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
// The folder is expanded; collapse it.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(item.item.id, item.key, false)
|
||||
toggleDirectoryExpansion(item.item.id, false)
|
||||
} else if (prevIndex != null) {
|
||||
// Focus parent if there is one.
|
||||
let index = prevIndex - 1
|
||||
@ -1403,7 +1329,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
// The folder is collapsed; expand it.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(item.item.id, item.key, true)
|
||||
toggleDirectoryExpansion(item.item.id, true)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -1493,23 +1419,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}, [setMostRecentlySelectedIndex])
|
||||
|
||||
const getNewProjectName = useEventCallback(
|
||||
(templateName: string | null, parentKey: DirectoryId | null) => {
|
||||
const prefix = `${templateName ?? 'New Project'} `
|
||||
const projectNameTemplate = new RegExp(`^${prefix}(?<projectIndex>\\d+)$`)
|
||||
const siblings =
|
||||
parentKey == null ?
|
||||
assetTree.children ?? []
|
||||
: nodeMapRef.current.get(parentKey)?.children ?? []
|
||||
const projectIndices = siblings
|
||||
.map((node) => node.item)
|
||||
.filter(assetIsProject)
|
||||
.map((item) => projectNameTemplate.exec(item.title)?.groups?.projectIndex)
|
||||
.map((maybeIndex) => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
|
||||
return `${prefix}${Math.max(0, ...projectIndices) + 1}`
|
||||
},
|
||||
)
|
||||
|
||||
const deleteAsset = useEventCallback((assetId: AssetId) => {
|
||||
const asset = nodeMapRef.current.get(assetId)?.item
|
||||
|
||||
@ -1529,379 +1438,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
const onAssetListEvent = useEventCallback((event: AssetListEvent) => {
|
||||
switch (event.type) {
|
||||
case AssetListEventType.newFolder: {
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
const siblings = parent?.children ?? []
|
||||
const directoryIndices = siblings
|
||||
.map((node) => node.item)
|
||||
.filter(assetIsDirectory)
|
||||
.map((item) => /^New Folder (?<directoryIndex>\d+)$/.exec(item.title))
|
||||
.map((match) => match?.groups?.directoryIndex)
|
||||
.map((maybeIndex) => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
|
||||
const title = `New Folder ${Math.max(0, ...directoryIndices) + 1}`
|
||||
const placeholderItem: DirectoryAsset = {
|
||||
type: AssetType.directory,
|
||||
id: DirectoryId(uniqueString()),
|
||||
title,
|
||||
modifiedAt: toRfc3339(new Date()),
|
||||
parentId: event.parentId,
|
||||
permissions: tryCreateOwnerPermission(
|
||||
`${parent?.path ?? ''}/${title}`,
|
||||
category,
|
||||
user,
|
||||
users ?? [],
|
||||
userGroups ?? [],
|
||||
),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
labels: [],
|
||||
description: null,
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
void createDirectoryMutation
|
||||
.mutateAsync([{ parentId: placeholderItem.parentId, title: placeholderItem.title }])
|
||||
.then(({ id }) => {
|
||||
setNewestFolderId(id)
|
||||
setSelectedKeys(new Set([id]))
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
case AssetListEventType.newProject: {
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
const projectName = getNewProjectName(event.preferredName, event.parentId)
|
||||
const dummyId = createPlaceholderAssetId(AssetType.project)
|
||||
const path =
|
||||
backend instanceof LocalBackend ? backend.joinPath(event.parentId, projectName) : null
|
||||
const placeholderItem: ProjectAsset = {
|
||||
type: AssetType.project,
|
||||
id: dummyId,
|
||||
title: projectName,
|
||||
modifiedAt: toRfc3339(new Date()),
|
||||
parentId: event.parentId,
|
||||
permissions: tryCreateOwnerPermission(
|
||||
`${parent?.path ?? ''}/${projectName}`,
|
||||
category,
|
||||
user,
|
||||
users ?? [],
|
||||
userGroups ?? [],
|
||||
),
|
||||
projectState: {
|
||||
type: ProjectState.placeholder,
|
||||
volumeId: '',
|
||||
openedBy: user.email,
|
||||
...(path != null ? { path } : {}),
|
||||
},
|
||||
extension: null,
|
||||
labels: [],
|
||||
description: null,
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
void createProjectMutation
|
||||
.mutateAsync([
|
||||
{
|
||||
parentDirectoryId: placeholderItem.parentId,
|
||||
projectName: placeholderItem.title,
|
||||
...(event.templateId == null ? {} : { projectTemplateName: event.templateId }),
|
||||
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
|
||||
},
|
||||
])
|
||||
.catch((error) => {
|
||||
event.onError?.()
|
||||
|
||||
deleteAsset(placeholderItem.id)
|
||||
toastAndLog('createProjectError', error)
|
||||
|
||||
throw error
|
||||
})
|
||||
.then((createdProject) => {
|
||||
event.onCreated?.(createdProject, placeholderItem.parentId)
|
||||
|
||||
doOpenProject({
|
||||
id: createdProject.projectId,
|
||||
type: backend.type,
|
||||
parentId: placeholderItem.parentId,
|
||||
title: placeholderItem.title,
|
||||
})
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
case AssetListEventType.uploadFiles: {
|
||||
const localBackend = backend instanceof LocalBackend ? backend : null
|
||||
const reversedFiles = Array.from(event.files).reverse()
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
const siblingNodes = parent?.children ?? []
|
||||
const siblings = siblingNodes.map((node) => node.item)
|
||||
const siblingFiles = siblings.filter(assetIsFile)
|
||||
const siblingProjects = siblings.filter(assetIsProject)
|
||||
const siblingFileTitles = new Set(siblingFiles.map((asset) => asset.title))
|
||||
const siblingProjectTitles = new Set(siblingProjects.map((asset) => asset.title))
|
||||
|
||||
const ownerPermission = tryCreateOwnerPermission(
|
||||
parent?.path ?? '',
|
||||
category,
|
||||
user,
|
||||
users ?? [],
|
||||
userGroups ?? [],
|
||||
)
|
||||
|
||||
const files = reversedFiles.filter(fileIsNotProject).map((file) => {
|
||||
const asset = createPlaceholderFileAsset(
|
||||
escapeSpecialCharacters(file.name),
|
||||
event.parentId,
|
||||
ownerPermission,
|
||||
)
|
||||
return { asset, file }
|
||||
})
|
||||
const projects = reversedFiles.filter(fileIsProject).map((file) => {
|
||||
const basename = escapeSpecialCharacters(stripProjectExtension(file.name))
|
||||
const asset = createPlaceholderProjectAsset(
|
||||
basename,
|
||||
event.parentId,
|
||||
ownerPermission,
|
||||
user,
|
||||
localBackend?.joinPath(event.parentId, basename) ?? null,
|
||||
)
|
||||
|
||||
return { asset, file }
|
||||
})
|
||||
const duplicateFiles = files.filter((file) => siblingFileTitles.has(file.asset.title))
|
||||
const duplicateProjects = projects.filter((project) =>
|
||||
siblingProjectTitles.has(stripProjectExtension(project.asset.title)),
|
||||
)
|
||||
const fileMap = new Map<AssetId, File>([
|
||||
...files.map(({ asset, file }) => [asset.id, file] as const),
|
||||
...projects.map(({ asset, file }) => [asset.id, file] as const),
|
||||
])
|
||||
const uploadedFileIds: AssetId[] = []
|
||||
const addIdToSelection = (id: AssetId) => {
|
||||
uploadedFileIds.push(id)
|
||||
const newIds = new Set(uploadedFileIds)
|
||||
setSelectedKeys(newIds)
|
||||
}
|
||||
|
||||
const doUploadFile = async (asset: AnyAsset, method: 'new' | 'update') => {
|
||||
const file = fileMap.get(asset.id)
|
||||
|
||||
if (file != null) {
|
||||
const fileId = method === 'new' ? null : asset.id
|
||||
|
||||
switch (true) {
|
||||
case assetIsProject(asset): {
|
||||
const { extension } = extractProjectExtension(file.name)
|
||||
const title = escapeSpecialCharacters(stripProjectExtension(asset.title))
|
||||
|
||||
await uploadFileMutation
|
||||
.mutateAsync(
|
||||
{
|
||||
fileId,
|
||||
fileName: `${title}.${extension}`,
|
||||
parentDirectoryId: asset.parentId,
|
||||
},
|
||||
file,
|
||||
)
|
||||
.then(({ id }) => {
|
||||
addIdToSelection(id)
|
||||
})
|
||||
.catch((error) => {
|
||||
toastAndLog('uploadProjectError', error)
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
case assetIsFile(asset): {
|
||||
const title = escapeSpecialCharacters(asset.title)
|
||||
await uploadFileMutation
|
||||
.mutateAsync({ fileId, fileName: title, parentDirectoryId: asset.parentId }, file)
|
||||
.then(({ id }) => {
|
||||
addIdToSelection(id)
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateFiles.length === 0 && duplicateProjects.length === 0) {
|
||||
const assets = [...files, ...projects].map(({ asset }) => asset)
|
||||
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
void Promise.all(assets.map((asset) => doUploadFile(asset, 'new')))
|
||||
} else {
|
||||
const siblingFilesByName = new Map(siblingFiles.map((file) => [file.title, file]))
|
||||
const siblingProjectsByName = new Map(
|
||||
siblingProjects.map((project) => [project.title, project]),
|
||||
)
|
||||
const conflictingFiles = duplicateFiles.map((file) => ({
|
||||
// This is SAFE, as `duplicateFiles` only contains files that have siblings
|
||||
// with the same name.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
current: siblingFilesByName.get(file.asset.title)!,
|
||||
new: createPlaceholderFileAsset(file.asset.title, event.parentId, ownerPermission),
|
||||
file: file.file,
|
||||
}))
|
||||
const conflictingProjects = duplicateProjects.map((project) => {
|
||||
const basename = stripProjectExtension(project.asset.title)
|
||||
return {
|
||||
// This is SAFE, as `duplicateProjects` only contains projects that have
|
||||
// siblings with the same name.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
current: siblingProjectsByName.get(basename)!,
|
||||
new: createPlaceholderProjectAsset(
|
||||
basename,
|
||||
event.parentId,
|
||||
ownerPermission,
|
||||
user,
|
||||
localBackend?.joinPath(event.parentId, basename) ?? null,
|
||||
),
|
||||
file: project.file,
|
||||
}
|
||||
})
|
||||
setModal(
|
||||
<DuplicateAssetsModal
|
||||
parentKey={event.parentKey}
|
||||
parentId={event.parentId}
|
||||
conflictingFiles={conflictingFiles}
|
||||
conflictingProjects={conflictingProjects}
|
||||
siblingFileNames={siblingFilesByName.keys()}
|
||||
siblingProjectNames={siblingProjectsByName.keys()}
|
||||
nonConflictingFileCount={files.length - conflictingFiles.length}
|
||||
nonConflictingProjectCount={projects.length - conflictingProjects.length}
|
||||
doUpdateConflicting={async (resolvedConflicts) => {
|
||||
await Promise.allSettled(
|
||||
resolvedConflicts.map((conflict) => {
|
||||
const isUpdating = conflict.current.title === conflict.new.title
|
||||
const asset = isUpdating ? conflict.current : conflict.new
|
||||
fileMap.set(asset.id, conflict.file)
|
||||
return doUploadFile(asset, isUpdating ? 'update' : 'new')
|
||||
}),
|
||||
)
|
||||
}}
|
||||
doUploadNonConflicting={async () => {
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
const newFiles = files
|
||||
.filter((file) => !siblingFileTitles.has(file.asset.title))
|
||||
.map((file) => {
|
||||
const asset = createPlaceholderFileAsset(
|
||||
file.asset.title,
|
||||
event.parentId,
|
||||
ownerPermission,
|
||||
)
|
||||
fileMap.set(asset.id, file.file)
|
||||
return asset
|
||||
})
|
||||
|
||||
const newProjects = projects
|
||||
.filter(
|
||||
(project) =>
|
||||
!siblingProjectTitles.has(stripProjectExtension(project.asset.title)),
|
||||
)
|
||||
.map((project) => {
|
||||
const basename = stripProjectExtension(project.asset.title)
|
||||
const asset = createPlaceholderProjectAsset(
|
||||
basename,
|
||||
event.parentId,
|
||||
ownerPermission,
|
||||
user,
|
||||
localBackend?.joinPath(event.parentId, basename) ?? null,
|
||||
)
|
||||
fileMap.set(asset.id, project.file)
|
||||
return asset
|
||||
})
|
||||
|
||||
const assets = [...newFiles, ...newProjects]
|
||||
|
||||
await Promise.allSettled(assets.map((asset) => doUploadFile(asset, 'new')))
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetListEventType.newDatalink: {
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
const placeholderItem: DatalinkAsset = {
|
||||
type: AssetType.datalink,
|
||||
id: DatalinkId(uniqueString()),
|
||||
title: event.name,
|
||||
modifiedAt: toRfc3339(new Date()),
|
||||
parentId: event.parentId,
|
||||
permissions: tryCreateOwnerPermission(
|
||||
`${parent?.path ?? ''}/${event.name}`,
|
||||
category,
|
||||
user,
|
||||
users ?? [],
|
||||
userGroups ?? [],
|
||||
),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
labels: [],
|
||||
description: null,
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
createDatalinkMutation.mutate([
|
||||
{
|
||||
parentDirectoryId: placeholderItem.parentId,
|
||||
datalinkId: null,
|
||||
name: placeholderItem.title,
|
||||
value: event.value,
|
||||
},
|
||||
])
|
||||
|
||||
break
|
||||
}
|
||||
case AssetListEventType.newSecret: {
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
const placeholderItem: SecretAsset = {
|
||||
type: AssetType.secret,
|
||||
id: SecretId(uniqueString()),
|
||||
title: event.name,
|
||||
modifiedAt: toRfc3339(new Date()),
|
||||
parentId: event.parentId,
|
||||
permissions: tryCreateOwnerPermission(
|
||||
`${parent?.path ?? ''}/${event.name}`,
|
||||
category,
|
||||
user,
|
||||
users ?? [],
|
||||
userGroups ?? [],
|
||||
),
|
||||
projectState: null,
|
||||
extension: null,
|
||||
labels: [],
|
||||
description: null,
|
||||
parentsPath: '',
|
||||
virtualParentsPath: '',
|
||||
}
|
||||
|
||||
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
|
||||
|
||||
createSecretMutation.mutate([
|
||||
{
|
||||
parentDirectoryId: placeholderItem.parentId,
|
||||
name: placeholderItem.title,
|
||||
value: event.value,
|
||||
},
|
||||
])
|
||||
|
||||
break
|
||||
}
|
||||
case AssetListEventType.duplicateProject: {
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
const siblings = parent?.children ?? []
|
||||
@ -1998,10 +1534,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: event.id })
|
||||
break
|
||||
}
|
||||
case AssetListEventType.closeFolder: {
|
||||
doToggleDirectoryExpansion(event.id, event.key, false)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -2050,7 +1582,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (pasteData.data.ids.has(newParentKey)) {
|
||||
toast.error('Cannot paste a folder into itself.')
|
||||
} else {
|
||||
doToggleDirectoryExpansion(newParentId, newParentKey, true)
|
||||
toggleDirectoryExpansion(newParentId, true)
|
||||
if (pasteData.type === 'copy') {
|
||||
const assets = Array.from(pasteData.data.ids, (id) => nodeMapRef.current.get(id)).flatMap(
|
||||
(asset) => (asset ? [asset.item] : []),
|
||||
@ -2124,12 +1656,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
void uploadFiles(Array.from(event.dataTransfer.files), rootDirectoryId, rootDirectoryId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2147,7 +1674,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setQuery,
|
||||
nodeMap: nodeMapRef,
|
||||
hideColumn,
|
||||
doToggleDirectoryExpansion,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
@ -2162,7 +1688,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
category,
|
||||
sortInfo,
|
||||
query,
|
||||
doToggleDirectoryExpansion,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
@ -2702,12 +2227,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
>
|
||||
<FileTrigger
|
||||
onSelect={(event) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
files: Array.from(event ?? []),
|
||||
})
|
||||
void uploadFiles(Array.from(event ?? []), rootDirectoryId, rootDirectoryId)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
|
@ -245,9 +245,11 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
<GlobalContextMenu
|
||||
hidden={hidden}
|
||||
backend={backend}
|
||||
category={category}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
directoryKey={null}
|
||||
directoryId={null}
|
||||
path={null}
|
||||
doPaste={doPaste}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,8 +1,6 @@
|
||||
/** @file The directory header bar and directory item listing. */
|
||||
import * as React from 'react'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as offlineHooks from '#/hooks/offlineHooks'
|
||||
@ -26,16 +24,10 @@ import Labels from '#/layouts/Labels'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as result from '#/components/Result'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useDriveStore } from '#/providers/DriveProvider'
|
||||
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
|
||||
import AssetQuery from '#/utilities/AssetQuery'
|
||||
import * as download from '#/utilities/download'
|
||||
import * as github from '#/utilities/github'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
|
||||
// =============
|
||||
// === Drive ===
|
||||
@ -62,27 +54,6 @@ export default function Drive(props: DriveProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
|
||||
const organizationQuery = useSuspenseQuery({
|
||||
queryKey: [backend.type, 'getOrganization'],
|
||||
queryFn: () => backend.getOrganization(),
|
||||
})
|
||||
const organization = organizationQuery.data ?? null
|
||||
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
|
||||
const rootDirectoryId = React.useMemo(() => {
|
||||
switch (category.type) {
|
||||
case 'user':
|
||||
case 'team': {
|
||||
return category.homeDirectoryId
|
||||
}
|
||||
default: {
|
||||
const localRootPath =
|
||||
localRootDirectory != null ? backendModule.Path(localRootDirectory) : null
|
||||
const id = backend.rootDirectoryId(user, organization, localRootPath)
|
||||
invariant(id, 'Missing root directory')
|
||||
return id
|
||||
}
|
||||
}
|
||||
}, [category, backend, user, organization, localRootDirectory])
|
||||
const isCloud = categoryModule.isCloudCategory(category)
|
||||
const supportLocalBackend = localBackend != null
|
||||
|
||||
@ -91,82 +62,10 @@ export default function Drive(props: DriveProps) {
|
||||
: isCloud && !user.isEnabled ? 'not-enabled'
|
||||
: 'ok'
|
||||
|
||||
const driveStore = useDriveStore()
|
||||
|
||||
const getTargetDirectory = React.useCallback(
|
||||
() => driveStore.getState().targetDirectory,
|
||||
[driveStore],
|
||||
)
|
||||
|
||||
const doUploadFiles = useEventCallback((files: File[]) => {
|
||||
if (isCloud && isOffline) {
|
||||
// This should never happen, however display a nice error message in case it does.
|
||||
toastAndLog('offlineUploadFilesError')
|
||||
} else {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
|
||||
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
|
||||
files,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const doEmptyTrash = React.useCallback(() => {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
|
||||
}, [dispatchAssetListEvent])
|
||||
|
||||
const doCreateProject = useEventCallback(
|
||||
(
|
||||
templateId: string | null = null,
|
||||
templateName: string | null = null,
|
||||
onCreated?: (
|
||||
project: backendModule.CreatedProject,
|
||||
parentId: backendModule.DirectoryId,
|
||||
) => void,
|
||||
onError?: () => void,
|
||||
) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newProject,
|
||||
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
|
||||
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
|
||||
templateId,
|
||||
datalinkId: null,
|
||||
preferredName: templateName,
|
||||
...(onCreated ? { onCreated } : {}),
|
||||
...(onError ? { onError } : {}),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const doCreateDirectory = useEventCallback(() => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newFolder,
|
||||
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
|
||||
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
|
||||
})
|
||||
})
|
||||
|
||||
const doCreateSecret = useEventCallback((name: string, value: string) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newSecret,
|
||||
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
|
||||
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
|
||||
name,
|
||||
value,
|
||||
})
|
||||
})
|
||||
|
||||
const doCreateDatalink = useEventCallback((name: string, value: unknown) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newDatalink,
|
||||
parentKey: getTargetDirectory()?.key ?? rootDirectoryId,
|
||||
parentId: getTargetDirectory()?.item.id ?? rootDirectoryId,
|
||||
name,
|
||||
value,
|
||||
})
|
||||
})
|
||||
|
||||
switch (status) {
|
||||
case 'not-enabled': {
|
||||
return (
|
||||
@ -216,11 +115,6 @@ export default function Drive(props: DriveProps) {
|
||||
setQuery={setQuery}
|
||||
category={category}
|
||||
doEmptyTrash={doEmptyTrash}
|
||||
doCreateProject={doCreateProject}
|
||||
doUploadFiles={doUploadFiles}
|
||||
doCreateDirectory={doCreateDirectory}
|
||||
doCreateSecret={doCreateSecret}
|
||||
doCreateDatalink={doCreateDatalink}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 gap-drive overflow-hidden">
|
||||
|
@ -4,7 +4,7 @@
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import AddDatalinkIcon from '#/assets/add_datalink.svg'
|
||||
import AddFolderIcon from '#/assets/add_folder.svg'
|
||||
@ -12,7 +12,6 @@ import AddKeyIcon from '#/assets/add_key.svg'
|
||||
import DataDownloadIcon from '#/assets/data_download.svg'
|
||||
import DataUploadIcon from '#/assets/data_upload.svg'
|
||||
import Plus2Icon from '#/assets/plus2.svg'
|
||||
import { Input as AriaInput } from '#/components/aria'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@ -21,8 +20,16 @@ import {
|
||||
useVisualTooltip,
|
||||
} from '#/components/AriaComponents'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import {
|
||||
useNewDatalink,
|
||||
useNewFolder,
|
||||
useNewProject,
|
||||
useNewSecret,
|
||||
useRootDirectoryId,
|
||||
useUploadFiles,
|
||||
} from '#/hooks/backendHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useOffline } from '#/hooks/offlineHooks'
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
|
||||
import AssetSearchBar from '#/layouts/AssetSearchBar'
|
||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
@ -35,13 +42,16 @@ import StartModal from '#/layouts/StartModal'
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
|
||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
import { useCanCreateAssets, useCanDownload, usePasteData } from '#/providers/DriveProvider'
|
||||
import {
|
||||
useCanCreateAssets,
|
||||
useCanDownload,
|
||||
useDriveStore,
|
||||
usePasteData,
|
||||
} from '#/providers/DriveProvider'
|
||||
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import type { DirectoryId } from '#/services/Backend'
|
||||
import { ProjectState, type CreatedProject, type ProjectId } from '#/services/Backend'
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import { inputFiles } from '#/utilities/input'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
@ -58,16 +68,6 @@ export interface DriveBarProps {
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
readonly category: Category
|
||||
readonly doEmptyTrash: () => void
|
||||
readonly doCreateProject: (
|
||||
templateId?: string | null,
|
||||
templateName?: string | null,
|
||||
onCreated?: (project: CreatedProject, parentId: DirectoryId) => void,
|
||||
onError?: () => void,
|
||||
) => void
|
||||
readonly doCreateDirectory: () => void
|
||||
readonly doCreateSecret: (name: string, value: string) => void
|
||||
readonly doCreateDatalink: (name: string, value: unknown) => void
|
||||
readonly doUploadFiles: (files: File[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@ -75,9 +75,7 @@ export interface DriveBarProps {
|
||||
* and a column display mode switcher.
|
||||
*/
|
||||
export default function DriveBar(props: DriveBarProps) {
|
||||
const { backend, query, setQuery, category } = props
|
||||
const { doEmptyTrash, doCreateProject, doCreateDirectory } = props
|
||||
const { doCreateSecret, doCreateDatalink, doUploadFiles } = props
|
||||
const { backend, query, setQuery, category, doEmptyTrash } = props
|
||||
|
||||
const [startModalDefaultOpen, , resetStartModalDefaultOpen] = useSearchParamsState(
|
||||
'startModalDefaultOpen',
|
||||
@ -86,11 +84,11 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
|
||||
const { unsetModal } = useSetModal()
|
||||
const { getText } = useText()
|
||||
const driveStore = useDriveStore()
|
||||
const inputBindings = useInputBindings()
|
||||
const dispatchAssetEvent = useDispatchAssetEvent()
|
||||
const canCreateAssets = useCanCreateAssets()
|
||||
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
|
||||
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
|
||||
const isCloud = isCloudCategory(category)
|
||||
const { isOffline } = useOffline()
|
||||
const canDownload = useCanDownload()
|
||||
@ -105,12 +103,6 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
targetRef: createAssetButtonsRef,
|
||||
overlayPositionProps: { placement: 'top' },
|
||||
})
|
||||
const [isCreatingProjectFromTemplate, setIsCreatingProjectFromTemplate] = React.useState(false)
|
||||
const [isCreatingProject, setIsCreatingProject] = React.useState(false)
|
||||
const [createdProjectId, setCreatedProjectId] = React.useState<{
|
||||
projectId: ProjectId
|
||||
parentId: DirectoryId
|
||||
} | null>(null)
|
||||
const pasteData = usePasteData()
|
||||
const effectivePasteData =
|
||||
(
|
||||
@ -120,60 +112,64 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
pasteData
|
||||
: null
|
||||
|
||||
const getTargetDirectory = useEventCallback(() => driveStore.getState().targetDirectory)
|
||||
const rootDirectoryId = useRootDirectoryId(backend, category)
|
||||
|
||||
const newFolderRaw = useNewFolder(backend, category)
|
||||
const newFolder = useEventCallback(async () => {
|
||||
const parent = getTargetDirectory()
|
||||
return await newFolderRaw(parent?.directoryId ?? rootDirectoryId, parent?.path)
|
||||
})
|
||||
const uploadFilesRaw = useUploadFiles(backend, category)
|
||||
const uploadFiles = useEventCallback(async (files: readonly File[]) => {
|
||||
const parent = getTargetDirectory()
|
||||
await uploadFilesRaw(files, parent?.directoryId ?? rootDirectoryId, parent?.path)
|
||||
})
|
||||
const newSecretRaw = useNewSecret(backend, category)
|
||||
const newSecret = useEventCallback(async (name: string, value: string) => {
|
||||
const parent = getTargetDirectory()
|
||||
return await newSecretRaw(name, value, parent?.directoryId ?? rootDirectoryId, parent?.path)
|
||||
})
|
||||
const newDatalinkRaw = useNewDatalink(backend, category)
|
||||
const newDatalink = useEventCallback(async (name: string, value: unknown) => {
|
||||
const parent = getTargetDirectory()
|
||||
return await newDatalinkRaw(name, value, parent?.directoryId ?? rootDirectoryId, parent?.path)
|
||||
})
|
||||
const newProjectRaw = useNewProject(backend, category)
|
||||
const newProjectMutation = useMutation({
|
||||
mutationKey: ['newProject'],
|
||||
mutationFn: async ([templateId, templateName]: [
|
||||
templateId: string | null | undefined,
|
||||
templateName: string | null | undefined,
|
||||
]) => {
|
||||
const parent = getTargetDirectory()
|
||||
return await newProjectRaw(
|
||||
{ templateName, templateId },
|
||||
parent?.directoryId ?? rootDirectoryId,
|
||||
parent?.path,
|
||||
)
|
||||
},
|
||||
})
|
||||
const newProject = newProjectMutation.mutateAsync
|
||||
const isCreatingProject = newProjectMutation.isPending
|
||||
|
||||
React.useEffect(() => {
|
||||
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
||||
...(isCloud ?
|
||||
{
|
||||
newFolder: () => {
|
||||
doCreateDirectory()
|
||||
void newFolder()
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
newProject: () => {
|
||||
setIsCreatingProject(true)
|
||||
doCreateProject(
|
||||
null,
|
||||
null,
|
||||
(project, parentId) => {
|
||||
setCreatedProjectId({ projectId: project.projectId, parentId })
|
||||
},
|
||||
() => {
|
||||
setIsCreatingProject(false)
|
||||
},
|
||||
)
|
||||
void newProject([null, null])
|
||||
},
|
||||
uploadFiles: () => {
|
||||
uploadFilesRef.current?.click()
|
||||
void inputFiles().then((files) => uploadFiles(Array.from(files)))
|
||||
},
|
||||
})
|
||||
}, [isCloud, doCreateDirectory, doCreateProject, inputBindings])
|
||||
|
||||
const createdProjectQuery = useQuery({
|
||||
...createGetProjectDetailsQuery({
|
||||
// This is safe because we disable the query when `createdProjectId` is `null`.
|
||||
// see `enabled` property below.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
assetId: createdProjectId?.projectId as ProjectId,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
parentId: createdProjectId?.parentId as DirectoryId,
|
||||
backend,
|
||||
}),
|
||||
enabled: createdProjectId != null,
|
||||
})
|
||||
|
||||
const isFetching =
|
||||
(createdProjectQuery.isLoading ||
|
||||
(createdProjectQuery.data &&
|
||||
createdProjectQuery.data.state.type !== ProjectState.opened &&
|
||||
createdProjectQuery.data.state.type !== ProjectState.closing)) ??
|
||||
false
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isFetching) {
|
||||
setIsCreatingProject(false)
|
||||
setIsCreatingProjectFromTemplate(false)
|
||||
}
|
||||
}, [isFetching])
|
||||
}, [inputBindings, isCloud, newFolder, newProject, uploadFiles])
|
||||
|
||||
const searchBar = (
|
||||
<AssetSearchBar backend={backend} isCloud={isCloud} query={query} setQuery={setQuery} />
|
||||
@ -246,9 +242,8 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
<Button
|
||||
size="medium"
|
||||
variant="accent"
|
||||
isDisabled={shouldBeDisabled || isCreatingProject || isCreatingProjectFromTemplate}
|
||||
isDisabled={shouldBeDisabled || isCreatingProject}
|
||||
icon={Plus2Icon}
|
||||
loading={isCreatingProjectFromTemplate}
|
||||
loaderPosition="icon"
|
||||
>
|
||||
{getText('startWithATemplate')}
|
||||
@ -256,40 +251,18 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
|
||||
<StartModal
|
||||
createProject={(templateId, templateName) => {
|
||||
setIsCreatingProjectFromTemplate(true)
|
||||
doCreateProject(
|
||||
templateId,
|
||||
templateName,
|
||||
({ projectId }, parentId) => {
|
||||
setCreatedProjectId({ projectId, parentId })
|
||||
},
|
||||
() => {
|
||||
setIsCreatingProjectFromTemplate(false)
|
||||
},
|
||||
)
|
||||
void newProject([templateId, templateName])
|
||||
}}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<Button
|
||||
size="medium"
|
||||
variant="outline"
|
||||
isDisabled={shouldBeDisabled || isCreatingProject || isCreatingProjectFromTemplate}
|
||||
isDisabled={shouldBeDisabled || isCreatingProject}
|
||||
icon={Plus2Icon}
|
||||
loading={isCreatingProject}
|
||||
loaderPosition="icon"
|
||||
onPress={() => {
|
||||
setIsCreatingProject(true)
|
||||
doCreateProject(
|
||||
null,
|
||||
null,
|
||||
({ projectId }, parentId) => {
|
||||
setCreatedProjectId({ projectId, parentId })
|
||||
setIsCreatingProject(false)
|
||||
},
|
||||
() => {
|
||||
setIsCreatingProject(false)
|
||||
},
|
||||
)
|
||||
onPress={async () => {
|
||||
await newProject([null, null])
|
||||
}}
|
||||
>
|
||||
{getText('newEmptyProject')}
|
||||
@ -301,8 +274,8 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
icon={AddFolderIcon}
|
||||
isDisabled={shouldBeDisabled}
|
||||
aria-label={getText('newFolder')}
|
||||
onPress={() => {
|
||||
doCreateDirectory()
|
||||
onPress={async () => {
|
||||
await newFolder()
|
||||
}}
|
||||
/>
|
||||
{isCloud && (
|
||||
@ -314,7 +287,13 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
isDisabled={shouldBeDisabled}
|
||||
aria-label={getText('newSecret')}
|
||||
/>
|
||||
<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />
|
||||
<UpsertSecretModal
|
||||
id={null}
|
||||
name={null}
|
||||
doCreate={async (name, value) => {
|
||||
await newSecret(name, value)
|
||||
}}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
|
||||
@ -327,23 +306,13 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
isDisabled={shouldBeDisabled}
|
||||
aria-label={getText('newDatalink')}
|
||||
/>
|
||||
<UpsertDatalinkModal doCreate={doCreateDatalink} />
|
||||
<UpsertDatalinkModal
|
||||
doCreate={async (name, value) => {
|
||||
await newDatalink(name, value)
|
||||
}}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<AriaInput
|
||||
ref={uploadFilesRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onInput={(event) => {
|
||||
if (event.currentTarget.files != null) {
|
||||
doUploadFiles(Array.from(event.currentTarget.files))
|
||||
}
|
||||
// Clear the list of selected files, otherwise `onInput` will not be
|
||||
// dispatched again if the same file is selected.
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="medium"
|
||||
@ -352,7 +321,7 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
aria-label={getText('uploadFiles')}
|
||||
onPress={async () => {
|
||||
const files = await inputFiles()
|
||||
doUploadFiles(Array.from(files))
|
||||
await uploadFiles(Array.from(files))
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
@ -1,34 +1,38 @@
|
||||
/** @file A context menu available everywhere in the directory. */
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||
|
||||
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
|
||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
|
||||
import { useDispatchAssetListEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import {
|
||||
useNewDatalink,
|
||||
useNewFolder,
|
||||
useNewProject,
|
||||
useNewSecret,
|
||||
useUploadFiles,
|
||||
} from '#/hooks/backendHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import { useDriveStore } from '#/providers/DriveProvider'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import { BackendType } from '#/services/Backend'
|
||||
import { BackendType, type DirectoryId } from '#/services/Backend'
|
||||
import { inputFiles } from '#/utilities/input'
|
||||
|
||||
/** Props for a {@link GlobalContextMenu}. */
|
||||
export interface GlobalContextMenuProps {
|
||||
readonly hidden?: boolean
|
||||
readonly backend: Backend
|
||||
readonly rootDirectoryId: backendModule.DirectoryId
|
||||
readonly directoryKey: backendModule.DirectoryId | null
|
||||
readonly directoryId: backendModule.DirectoryId | null
|
||||
readonly doPaste: (
|
||||
newParentKey: backendModule.DirectoryId,
|
||||
newParentId: backendModule.DirectoryId,
|
||||
) => void
|
||||
readonly category: Category
|
||||
readonly rootDirectoryId: DirectoryId
|
||||
readonly directoryKey: DirectoryId | null
|
||||
readonly directoryId: DirectoryId | null
|
||||
readonly path: string | null
|
||||
readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => void
|
||||
}
|
||||
|
||||
/** A context menu available everywhere in the directory. */
|
||||
@ -40,15 +44,17 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
||||
const {
|
||||
hidden = false,
|
||||
backend,
|
||||
category,
|
||||
directoryKey = null,
|
||||
directoryId = null,
|
||||
path,
|
||||
rootDirectoryId,
|
||||
} = props
|
||||
const { doPaste } = props
|
||||
|
||||
const { getText } = useText()
|
||||
const { setModal, unsetModal } = useSetModal()
|
||||
const dispatchAssetListEvent = useDispatchAssetListEvent()
|
||||
const isCloud = backend.type === BackendType.remote
|
||||
|
||||
const driveStore = useDriveStore()
|
||||
const hasPasteData = useStore(
|
||||
@ -56,7 +62,28 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
||||
(storeState) => (storeState.pasteData?.data.ids.size ?? 0) > 0,
|
||||
)
|
||||
|
||||
const isCloud = backend.type === BackendType.remote
|
||||
const newFolderRaw = useNewFolder(backend, category)
|
||||
const newFolder = useEventCallback(async () => {
|
||||
return await newFolderRaw(directoryId ?? rootDirectoryId, path)
|
||||
})
|
||||
const newSecretRaw = useNewSecret(backend, category)
|
||||
const newSecret = useEventCallback(async (name: string, value: string) => {
|
||||
return await newSecretRaw(name, value, directoryId ?? rootDirectoryId, path)
|
||||
})
|
||||
const newProjectRaw = useNewProject(backend, category)
|
||||
const newProject = useEventCallback(
|
||||
async (templateId: string | null | undefined, templateName: string | null | undefined) => {
|
||||
return await newProjectRaw({ templateName, templateId }, directoryId ?? rootDirectoryId, path)
|
||||
},
|
||||
)
|
||||
const newDatalinkRaw = useNewDatalink(backend, category)
|
||||
const newDatalink = useEventCallback(async (name: string, value: unknown) => {
|
||||
return await newDatalinkRaw(name, value, directoryId ?? rootDirectoryId, path)
|
||||
})
|
||||
const uploadFilesRaw = useUploadFiles(backend, category)
|
||||
const uploadFiles = useEventCallback(async (files: readonly File[]) => {
|
||||
await uploadFilesRaw(files, directoryId ?? rootDirectoryId, path)
|
||||
})
|
||||
|
||||
return (
|
||||
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
|
||||
@ -65,12 +92,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
||||
action="uploadFiles"
|
||||
doAction={async () => {
|
||||
const files = await inputFiles()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
files: Array.from(files),
|
||||
})
|
||||
await uploadFiles(Array.from(files))
|
||||
}}
|
||||
/>
|
||||
<ContextMenuEntry
|
||||
@ -78,14 +100,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
||||
action="newProject"
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newProject,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
templateId: null,
|
||||
datalinkId: null,
|
||||
preferredName: null,
|
||||
})
|
||||
void newProject(null, null)
|
||||
}}
|
||||
/>
|
||||
<ContextMenuEntry
|
||||
@ -93,11 +108,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
||||
action="newFolder"
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newFolder,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
})
|
||||
void newFolder()
|
||||
}}
|
||||
/>
|
||||
{isCloud && (
|
||||
@ -109,14 +120,8 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
||||
<UpsertSecretModal
|
||||
id={null}
|
||||
name={null}
|
||||
doCreate={(name, value) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newSecret,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
name,
|
||||
value,
|
||||
})
|
||||
doCreate={async (name, value) => {
|
||||
await newSecret(name, value)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
@ -130,14 +135,8 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<UpsertDatalinkModal
|
||||
doCreate={(name, value) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newDatalink,
|
||||
parentKey: directoryKey ?? rootDirectoryId,
|
||||
parentId: directoryId ?? rootDirectoryId,
|
||||
name,
|
||||
value,
|
||||
})
|
||||
doCreate={async (name, value) => {
|
||||
await newDatalink(name, value)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
@ -4,6 +4,7 @@ import * as React from 'react'
|
||||
import * as zustand from '#/utilities/zustand'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import type { AssetPanelContextProps } from '#/layouts/AssetPanel'
|
||||
import type { Suggestion } from '#/layouts/AssetSearchBar'
|
||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||
@ -18,7 +19,6 @@ import type {
|
||||
DirectoryId,
|
||||
} from 'enso-common/src/services/Backend'
|
||||
import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array'
|
||||
import { useEventCallback } from '../hooks/eventCallbackHooks'
|
||||
|
||||
// ==================
|
||||
// === DriveStore ===
|
||||
@ -45,6 +45,8 @@ interface DriveStore {
|
||||
readonly setCanDownload: (canDownload: boolean) => void
|
||||
readonly pasteData: PasteData<DrivePastePayload> | null
|
||||
readonly setPasteData: (pasteData: PasteData<DrivePastePayload> | null) => void
|
||||
readonly expandedDirectoryIds: readonly DirectoryId[]
|
||||
readonly setExpandedDirectoryIds: (selectedKeys: readonly DirectoryId[]) => void
|
||||
readonly selectedKeys: ReadonlySet<AssetId>
|
||||
readonly setSelectedKeys: (selectedKeys: ReadonlySet<AssetId>) => void
|
||||
readonly visuallySelectedKeys: ReadonlySet<AssetId> | null
|
||||
@ -137,6 +139,12 @@ export default function DriveProvider(props: ProjectsProviderProps) {
|
||||
set({ pasteData })
|
||||
}
|
||||
},
|
||||
expandedDirectoryIds: EMPTY_ARRAY,
|
||||
setExpandedDirectoryIds: (expandedDirectoryIds) => {
|
||||
if (get().expandedDirectoryIds !== expandedDirectoryIds) {
|
||||
set({ expandedDirectoryIds })
|
||||
}
|
||||
},
|
||||
selectedKeys: EMPTY_SET,
|
||||
setSelectedKeys: (selectedKeys) => {
|
||||
if (get().selectedKeys !== selectedKeys) {
|
||||
@ -299,13 +307,25 @@ export function useSetPasteData() {
|
||||
return zustand.useStore(store, (state) => state.setPasteData)
|
||||
}
|
||||
|
||||
/** The expanded directories in the Asset Table. */
|
||||
export function useExpandedDirectoryIds() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.expandedDirectoryIds)
|
||||
}
|
||||
|
||||
/** A function to set the expanded directoyIds in the Asset Table. */
|
||||
export function useSetExpandedDirectoryIds() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setExpandedDirectoryIds)
|
||||
}
|
||||
|
||||
/** The selected keys in the Asset Table. */
|
||||
export function useSelectedKeys() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.selectedKeys)
|
||||
}
|
||||
|
||||
/** A function to set the selected keys of the Asset Table selection. */
|
||||
/** A function to set the selected keys in the Asset Table. */
|
||||
export function useSetSelectedKeys() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setSelectedKeys)
|
||||
@ -482,3 +502,25 @@ export function useSetIsAssetPanelHidden() {
|
||||
const store = useDriveStore()
|
||||
return zustand.useStore(store, (state) => state.setIsAssetPanelHidden)
|
||||
}
|
||||
|
||||
/** Toggle whether a specific directory is expanded. */
|
||||
export function useToggleDirectoryExpansion() {
|
||||
const driveStore = useDriveStore()
|
||||
const setExpandedDirectoryIds = useSetExpandedDirectoryIds()
|
||||
|
||||
return useEventCallback((directoryId: DirectoryId, override?: boolean) => {
|
||||
const expandedDirectoryIds = driveStore.getState().expandedDirectoryIds
|
||||
const isExpanded = expandedDirectoryIds.includes(directoryId)
|
||||
const shouldExpand = override ?? !isExpanded
|
||||
|
||||
if (shouldExpand !== isExpanded) {
|
||||
React.startTransition(() => {
|
||||
if (shouldExpand) {
|
||||
setExpandedDirectoryIds([...expandedDirectoryIds, directoryId])
|
||||
} else {
|
||||
setExpandedDirectoryIds(expandedDirectoryIds.filter((id) => id !== directoryId))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user