Option to use Data Link in new project (#9644)

- Close https://github.com/enso-org/cloud-v2/issues/1161
- Add context menu option (and associated shortcut) to open a Data Link in a new project

# Important Notes
- The corresponding backend endpoints do not yet exist. The frontend currently passes an additional parameter `dataLinkId: string | null` to the backend's `create_project` endpoint. Note that this is inconsistent with the rest of the backend's terminology which calls Data Links `connector`s, so the parameter name might want to be changed.
This commit is contained in:
somebody1234 2024-04-12 03:13:59 +10:00 committed by GitHub
parent e3afa5561d
commit 3ecc3aebd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 195 additions and 134 deletions

View File

@ -46,6 +46,7 @@ const ACTION_TO_TEXT_ID: Readonly<Record<inputBindings.DashboardBindingKey, text
newFolder: 'newFolderShortcut',
newDataLink: 'newDataLinkShortcut',
newSecret: 'newSecretShortcut',
useInNewProject: 'useInNewProjectShortcut',
closeModal: 'closeModalShortcut',
cancelEditName: 'cancelEditNameShortcut',
signIn: 'signInShortcut',

View File

@ -32,6 +32,7 @@ import * as backendModule from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import AssetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
@ -61,8 +62,8 @@ const DRAG_EXPAND_DELAY_MS = 500
/** Common properties for state and setters passed to event handlers on an {@link AssetRow}. */
export interface AssetRowInnerProps {
readonly key: backendModule.AssetId
readonly item: AssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
readonly item: assetTreeNode.AnyAssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
readonly state: assetsTable.AssetsTableState
readonly rowState: assetsTable.AssetRowState
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
@ -71,7 +72,7 @@ export interface AssetRowInnerProps {
/** Props for an {@link AssetRow}. */
export interface AssetRowProps
extends Readonly<Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'>> {
readonly item: AssetTreeNode
readonly item: assetTreeNode.AnyAssetTreeNode
readonly state: assetsTable.AssetsTableState
readonly hidden: boolean
readonly columns: columnUtils.Column[]
@ -189,7 +190,7 @@ export default function AssetRow(props: AssetRowProps) {
const doMove = React.useCallback(
async (
newParentKey: backendModule.AssetId | null,
newParentKey: backendModule.DirectoryId | null,
newParentId: backendModule.DirectoryId | null
) => {
const rootDirectoryId = user?.rootDirectoryId ?? backendModule.DirectoryId('')
@ -726,7 +727,7 @@ export default function AssetRow(props: AssetRowProps) {
unsetModal()
onClick(innerProps, event)
if (
asset.type === backendModule.AssetType.directory &&
item.type === backendModule.AssetType.directory &&
eventModule.isDoubleClick(event) &&
!rowState.isEditingName
) {
@ -735,7 +736,7 @@ export default function AssetRow(props: AssetRowProps) {
window.setTimeout(() => {
setSelected(false)
})
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
doToggleDirectoryExpansion(item.item.id, item.key, asset.title)
}
}}
onContextMenu={event => {
@ -772,9 +773,9 @@ export default function AssetRow(props: AssetRowProps) {
if (dragOverTimeoutHandle.current != null) {
window.clearTimeout(dragOverTimeoutHandle.current)
}
if (backendModule.assetIsDirectory(asset)) {
if (item.type === backendModule.AssetType.directory) {
dragOverTimeoutHandle.current = window.setTimeout(() => {
doToggleDirectoryExpansion(asset.id, item.key, asset.title, true)
doToggleDirectoryExpansion(item.item.id, item.key, asset.title, true)
}, DRAG_EXPAND_DELAY_MS)
}
// Required because `dragover` does not fire on `mouseenter`.
@ -814,7 +815,7 @@ export default function AssetRow(props: AssetRowProps) {
props.onDrop?.(event)
clearDragState()
const [directoryKey, directoryId, directoryTitle] =
item.item.type === backendModule.AssetType.directory
item.type === backendModule.AssetType.directory
? [item.key, item.item.id, asset.title]
: [item.directoryKey, item.directoryId, null]
const payload = drag.ASSET_ROWS.lookup(event)
@ -841,10 +842,7 @@ export default function AssetRow(props: AssetRowProps) {
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
// This is SAFE, as it is guarded by the condition above:
// `item.item.type === backendModule.AssetType.directory`
// eslint-disable-next-line no-restricted-syntax
parentKey: directoryKey as backendModule.DirectoryId,
parentKey: directoryKey,
parentId: directoryId,
files: Array.from(event.dataTransfer.files),
})

View File

@ -39,11 +39,11 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) {
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const inputBindings = inputBindingsProvider.useInputBindings()
const asset = item.item
if (asset.type !== backendModule.AssetType.dataLink) {
if (item.type !== backendModule.AssetType.dataLink) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`DataLinkNameColumn` can only display Data Links.')
}
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const setIsEditing = (isEditingName: boolean) => {

View File

@ -45,11 +45,11 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const asset = item.item
if (asset.type !== backendModule.AssetType.directory) {
if (item.type !== backendModule.AssetType.directory) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`DirectoryNameColumn` can only display folders.')
}
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const isCloud = backend.type === backendModule.BackendType.remote

View File

@ -39,11 +39,11 @@ export default function FileNameColumn(props: FileNameColumnProps) {
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const inputBindings = inputBindingsProvider.useInputBindings()
const asset = item.item
if (asset.type !== backendModule.AssetType.file) {
if (item.type !== backendModule.AssetType.file) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`FileNameColumn` can only display files.')
}
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const setIsEditing = (isEditingName: boolean) => {

View File

@ -51,11 +51,11 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const asset = item.item
if (asset.type !== backendModule.AssetType.project) {
if (item.type !== backendModule.AssetType.project) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`ProjectNameColumn` can only display projects.')
}
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const ownPermission =
asset.permissions?.find(permission => permission.user.userId === user?.userId) ?? null
@ -142,7 +142,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const createdProject = await backend.createProject({
parentDirectoryId: asset.parentId,
projectName: asset.title,
projectTemplateName: event.templateId,
...(event.templateId == null ? {} : { projectTemplateName: event.templateId }),
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
})
rowState.setVisibility(Visibility.visible)
setAsset(

View File

@ -44,11 +44,11 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
const { setModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const inputBindings = inputBindingsProvider.useInputBindings()
const asset = item.item
if (asset.type !== backendModule.AssetType.secret) {
if (item.type !== backendModule.AssetType.secret) {
// eslint-disable-next-line no-restricted-syntax
throw new Error('`SecretNameColumn` can only display secrets.')
}
const asset = item.item
const setIsEditing = (isEditingName: boolean) => {
if (isEditable) {

View File

@ -13,7 +13,7 @@ import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import type * as backendModule from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
// ===================
// === AssetColumn ===
@ -22,8 +22,8 @@ import type AssetTreeNode from '#/utilities/AssetTreeNode'
/** Props for an arbitrary variant of {@link backendModule.Asset}. */
export interface AssetColumnProps {
readonly keyProp: backendModule.AssetId
readonly item: AssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
readonly item: assetTreeNode.AnyAssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
readonly selected: boolean
readonly setSelected: (selected: boolean) => void
readonly isSoleSelected: boolean

View File

@ -86,6 +86,11 @@ export const BINDINGS = inputBindings.defineBindings({
bindings: !detect.isOnMacOS() ? ['Mod+Alt+Shift+N'] : ['Mod+Alt+Shift+N', 'Mod+Alt+Shift+~'],
icon: AddConnectorIcon,
},
useInNewProject: {
name: 'Use In New Project',
bindings: ['Mod+P'],
icon: AddNetworkIcon,
},
signIn: { name: 'Login', bindings: [], icon: SignInIcon },
signOut: { name: 'Logout', bindings: [], icon: SignOutIcon, color: 'rgb(243 24 10 / 0.87)' },
// These should not appear in any menus.

View File

@ -3,7 +3,7 @@ import type AssetEventType from '#/events/AssetEventType'
import type * as spinner from '#/components/Spinner'
import type * as backendModule from '#/services/Backend'
import type * as backend from '#/services/Backend'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
@ -61,93 +61,94 @@ type SanityCheck<
/** A signal to create a project. */
export interface AssetNewProjectEvent extends AssetBaseEvent<AssetEventType.newProject> {
readonly placeholderId: backendModule.ProjectId
readonly placeholderId: backend.ProjectId
readonly templateId: string | null
readonly datalinkId: backend.ConnectorId | null
readonly onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
/** A signal to create a directory. */
export interface AssetNewFolderEvent extends AssetBaseEvent<AssetEventType.newFolder> {
readonly placeholderId: backendModule.DirectoryId
readonly placeholderId: backend.DirectoryId
}
/** A signal to upload files. */
export interface AssetUploadFilesEvent extends AssetBaseEvent<AssetEventType.uploadFiles> {
readonly files: ReadonlyMap<backendModule.AssetId, File>
readonly files: ReadonlyMap<backend.AssetId, File>
}
/** A signal to update files with new versions. */
export interface AssetUpdateFilesEvent extends AssetBaseEvent<AssetEventType.updateFiles> {
readonly files: ReadonlyMap<backendModule.AssetId, File>
readonly files: ReadonlyMap<backend.AssetId, File>
}
/** A signal to create a Data Link. */
export interface AssetNewDataLinkEvent extends AssetBaseEvent<AssetEventType.newDataLink> {
readonly placeholderId: backendModule.ConnectorId
readonly placeholderId: backend.ConnectorId
readonly value: unknown
}
/** A signal to create a secret. */
export interface AssetNewSecretEvent extends AssetBaseEvent<AssetEventType.newSecret> {
readonly placeholderId: backendModule.SecretId
readonly placeholderId: backend.SecretId
readonly value: string
}
/** A signal to open the specified project. */
export interface AssetOpenProjectEvent extends AssetBaseEvent<AssetEventType.openProject> {
readonly id: backendModule.ProjectId
readonly id: backend.ProjectId
readonly shouldAutomaticallySwitchPage: boolean
readonly runInBackground: boolean
}
/** A signal to close the specified project. */
export interface AssetCloseProjectEvent extends AssetBaseEvent<AssetEventType.closeProject> {
readonly id: backendModule.ProjectId
readonly id: backend.ProjectId
}
/** A signal that multiple assets should be copied. `ids` are the `Id`s of the newly created
* placeholder items. */
export interface AssetCopyEvent extends AssetBaseEvent<AssetEventType.copy> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly newParentKey: backendModule.AssetId
readonly newParentId: backendModule.DirectoryId
readonly ids: ReadonlySet<backend.AssetId>
readonly newParentKey: backend.AssetId
readonly newParentId: backend.DirectoryId
}
/** A signal to cut multiple assets. */
export interface AssetCutEvent extends AssetBaseEvent<AssetEventType.cut> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly ids: ReadonlySet<backend.AssetId>
}
/** A signal that a cut operation has been cancelled. */
export interface AssetCancelCutEvent extends AssetBaseEvent<AssetEventType.cancelCut> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly ids: ReadonlySet<backend.AssetId>
}
/** A signal to move multiple assets. */
export interface AssetMoveEvent extends AssetBaseEvent<AssetEventType.move> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly newParentKey: backendModule.AssetId
readonly newParentId: backendModule.DirectoryId
readonly ids: ReadonlySet<backend.AssetId>
readonly newParentKey: backend.DirectoryId
readonly newParentId: backend.DirectoryId
}
/** A signal to delete assets. */
export interface AssetDeleteEvent extends AssetBaseEvent<AssetEventType.delete> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly ids: ReadonlySet<backend.AssetId>
}
/** A signal to delete assets forever. */
export interface AssetDeleteForeverEvent extends AssetBaseEvent<AssetEventType.deleteForever> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly ids: ReadonlySet<backend.AssetId>
}
/** A signal to restore assets from trash. */
export interface AssetRestoreEvent extends AssetBaseEvent<AssetEventType.restore> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly ids: ReadonlySet<backend.AssetId>
}
/** A signal to download assets. */
export interface AssetDownloadEvent extends AssetBaseEvent<AssetEventType.download> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly ids: ReadonlySet<backend.AssetId>
}
/** A signal to download the currently selected assets. */
@ -156,38 +157,38 @@ export interface AssetDownloadSelectedEvent
/** A signal to remove the current user's permissions for an asset. */
export interface AssetRemoveSelfEvent extends AssetBaseEvent<AssetEventType.removeSelf> {
readonly id: backendModule.AssetId
readonly id: backend.AssetId
}
/** A signal to temporarily add labels to the selected assetss. */
export interface AssetTemporarilyAddLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName>
readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName>
}
/** A signal to temporarily remove labels from the selected assetss. */
export interface AssetTemporarilyRemoveLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName>
readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName>
}
/** A signal to add labels to the selected assetss. */
export interface AssetAddLabelsEvent extends AssetBaseEvent<AssetEventType.addLabels> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName>
readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName>
}
/** A signal to remove labels from the selected assetss. */
export interface AssetRemoveLabelsEvent extends AssetBaseEvent<AssetEventType.removeLabels> {
readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName>
readonly ids: ReadonlySet<backend.AssetId>
readonly labelNames: ReadonlySet<backend.LabelName>
}
/** A signal to remove a label from all assets. */
export interface AssetDeleteLabelEvent extends AssetBaseEvent<AssetEventType.deleteLabel> {
readonly labelName: backendModule.LabelName
readonly labelName: backend.LabelName
}
/** Every possible type of asset event. */

View File

@ -62,7 +62,8 @@ interface AssetListNewProjectEvent extends AssetListBaseEvent<AssetListEventType
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly templateId: string | null
readonly templateName: string | null
readonly datalinkId: backend.ConnectorId | null
readonly preferredName: string | null
readonly onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
@ -104,7 +105,7 @@ interface AssetListCloseFolderEvent extends AssetListBaseEvent<AssetListEventTyp
/** A signal that files should be copied. */
interface AssetListCopyEvent extends AssetListBaseEvent<AssetListEventType.copy> {
readonly newParentKey: backend.AssetId
readonly newParentKey: backend.DirectoryId
readonly newParentId: backend.DirectoryId
readonly items: backend.AnyAsset[]
}
@ -112,7 +113,7 @@ interface AssetListCopyEvent extends AssetListBaseEvent<AssetListEventType.copy>
/** A signal that a file has been moved. */
interface AssetListMoveEvent extends AssetListBaseEvent<AssetListEventType.move> {
readonly key: backend.AssetId
readonly newParentKey: backend.AssetId
readonly newParentKey: backend.DirectoryId
readonly newParentId: backend.DirectoryId
readonly item: backend.AnyAsset
}

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import type * as backend from '#/services/Backend'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import AssetTreeNode from '#/utilities/AssetTreeNode'
// ===================
@ -17,7 +18,7 @@ import AssetTreeNode from '#/utilities/AssetTreeNode'
* has been done. */
export function useSetAsset<T extends backend.AnyAsset>(
_value: T,
setNode: React.Dispatch<React.SetStateAction<AssetTreeNode>>
setNode: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
) {
return React.useCallback(
(valueOrUpdater: React.SetStateAction<T>) => {

View File

@ -52,7 +52,7 @@ export interface AssetContextMenuProps {
readonly doCut: () => void
readonly doTriggerDescriptionEdit: () => void
readonly doPaste: (
newParentKey: backendModule.AssetId,
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId
) => void
}
@ -135,6 +135,24 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
) : (
<ContextMenus hidden={hidden} key={asset.id} event={event}>
<ContextMenu aria-label={getText('assetContextMenuLabel')} hidden={hidden}>
{asset.type === backendModule.AssetType.dataLink && (
<ContextMenuEntry
hidden={hidden}
action="useInNewProject"
doAction={() => {
unsetModal()
dispatchAssetListEvent({
type: AssetListEventType.newProject,
parentId: item.directoryId,
parentKey: item.directoryKey,
templateId: null,
datalinkId: asset.id,
preferredName: asset.title,
onSpinnerStateChange: null,
})
}}
/>
)}
{asset.type === backendModule.AssetType.project &&
canExecute &&
!isRunningProject &&
@ -381,7 +399,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
action="paste"
doAction={() => {
const [directoryKey, directoryId] =
item.item.type === backendModule.AssetType.directory
item.type === backendModule.AssetType.directory
? [item.key, item.item.id]
: [item.directoryKey, item.directoryId]
doPaste(directoryKey, directoryId)

View File

@ -16,7 +16,7 @@ import * as backend from '#/services/Backend'
import * as array from '#/utilities/array'
import type AssetQuery from '#/utilities/AssetQuery'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import LocalStorage from '#/utilities/LocalStorage'
// =====================
@ -51,8 +51,8 @@ LocalStorage.registerKey('assetPanelTab', {
/** The subset of {@link AssetPanelProps} that are required to be supplied by the row. */
export interface AssetPanelRequiredProps {
readonly item: AssetTreeNode | null
readonly setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>> | null
readonly item: assetTreeNode.AnyAssetTreeNode | null
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>> | null
}
/** Props for an {@link AssetPanel}. */

View File

@ -26,7 +26,7 @@ import UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
@ -36,8 +36,8 @@ import * as permissions from '#/utilities/permissions'
/** Props for an {@link AssetPropertiesProps}. */
export interface AssetPropertiesProps {
readonly item: AssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
readonly item: assetTreeNode.AnyAssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
readonly category: Category
readonly labels: backendModule.Label[]
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
@ -75,7 +75,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
[dataLinkValue]
)
const setItem = React.useCallback(
(valueOrUpdater: React.SetStateAction<AssetTreeNode>) => {
(valueOrUpdater: React.SetStateAction<assetTreeNode.AnyAssetTreeNode>) => {
setItemInner(valueOrUpdater)
setItemRaw(valueOrUpdater)
},

View File

@ -49,6 +49,7 @@ import LocalBackend from '#/services/LocalBackend'
import * as array from '#/utilities/array'
import type * as assetQuery from '#/utilities/AssetQuery'
import AssetQuery from '#/utilities/AssetQuery'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import AssetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as drag from '#/utilities/drag'
@ -191,11 +192,11 @@ const SUGGESTIONS_FOR_NEGATIVE_TYPE: assetSearchBar.Suggestion[] = [
/** Return a directory, with new children added into its list of children.
* All children MUST have the same asset type. */
function insertAssetTreeNodeChildren(
item: AssetTreeNode,
item: assetTreeNode.AnyAssetTreeNode,
children: backendModule.AnyAsset[],
directoryKey: backendModule.AssetId,
directoryKey: backendModule.DirectoryId,
directoryId: backendModule.DirectoryId
): AssetTreeNode {
): assetTreeNode.AnyAssetTreeNode {
const depth = item.depth + 1
const typeOrder = children[0] != null ? backendModule.ASSET_TYPE_ORDER[children[0].type] : 0
const nodes = (item.children ?? []).filter(
@ -215,12 +216,12 @@ function insertAssetTreeNodeChildren(
/** Return a directory, with new children added into its list of children.
* The children MAY be of different asset types. */
function insertArbitraryAssetTreeNodeChildren(
item: AssetTreeNode,
item: assetTreeNode.AnyAssetTreeNode,
children: backendModule.AnyAsset[],
directoryKey: backendModule.AssetId,
directoryKey: backendModule.DirectoryId,
directoryId: backendModule.DirectoryId,
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
): AssetTreeNode {
): assetTreeNode.AnyAssetTreeNode {
const depth = item.depth + 1
const nodes = (item.children ?? []).filter(
node => node.item.type !== backendModule.AssetType.specialEmpty
@ -243,7 +244,13 @@ function insertArbitraryAssetTreeNodeChildren(
if (firstChild) {
const typeOrder = backendModule.ASSET_TYPE_ORDER[firstChild.type]
const nodesToInsert = childrenOfSpecificType.map(asset =>
AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth, getKey)
AssetTreeNode.fromAsset(
asset,
directoryKey,
directoryId,
depth,
getKey?.(asset) ?? asset.id
)
)
newNodes = array.splicedBefore(
newNodes,
@ -300,12 +307,12 @@ export interface AssetsTableState {
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly nodeMap: Readonly<
React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>
React.MutableRefObject<ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>>
>
readonly hideColumn: (column: columnUtils.Column) => void
readonly doToggleDirectoryExpansion: (
directoryId: backendModule.DirectoryId,
key: backendModule.AssetId,
key: backendModule.DirectoryId,
title?: string | null,
override?: boolean
) => void
@ -321,7 +328,7 @@ export interface AssetsTableState {
readonly doCopy: () => void
readonly doCut: () => void
readonly doPaste: (
newParentKey: backendModule.AssetId,
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId
) => void
}
@ -356,7 +363,7 @@ export interface AssetsTableProps {
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly targetDirectoryNodeRef: React.MutableRefObject<AssetTreeNode<backendModule.DirectoryAsset> | null>
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
readonly doOpenEditor: (
project: backendModule.ProjectAsset,
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
@ -401,7 +408,7 @@ export default function AssetsTable(props: AssetsTableProps) {
() => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''),
[backend, user]
)
const [assetTree, setAssetTree] = React.useState<AssetTreeNode>(() => {
const [assetTree, setAssetTree] = React.useState<assetTreeNode.AnyAssetTreeNode>(() => {
const rootParentDirectoryId = backendModule.DirectoryId('')
return AssetTreeNode.fromAsset(
backendModule.createRootDirectoryAsset(rootDirectoryId),
@ -415,19 +422,19 @@ export default function AssetsTable(props: AssetsTableProps) {
const queuedAssetListEventsRef = React.useRef<assetListEvent.AssetListEvent[]>([])
const rootRef = React.useRef<HTMLDivElement | null>(null)
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
const assetTreeRef = React.useRef<AssetTreeNode>(assetTree)
const assetTreeRef = React.useRef<assetTreeNode.AnyAssetTreeNode>(assetTree)
const pasteDataRef = React.useRef<pasteDataModule.PasteData<
ReadonlySet<backendModule.AssetId>
> | null>(null)
const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>(
new Map<backendModule.AssetId, AssetTreeNode>()
)
const nodeMapRef = React.useRef<
ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>
>(new Map<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>())
const filter = React.useMemo(() => {
const globCache: Record<string, RegExp> = {}
if (/^\s*$/.test(query.query)) {
return null
} else {
return (node: AssetTreeNode) => {
return (node: assetTreeNode.AnyAssetTreeNode) => {
if (
node.item.type === backendModule.AssetType.specialEmpty ||
node.item.type === backendModule.AssetType.specialLoading
@ -538,7 +545,7 @@ export default function AssetsTable(props: AssetsTableProps) {
return assetTree.preorderTraversal()
} else {
const multiplier = sortInfo.direction === sorting.SortDirection.ascending ? 1 : -1
let compare: (a: AssetTreeNode, b: AssetTreeNode) => number
let compare: (a: assetTreeNode.AnyAssetTreeNode, b: assetTreeNode.AnyAssetTreeNode) => number
switch (sortInfo.field) {
case columnUtils.Column.name: {
compare = (a, b) => {
@ -568,7 +575,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}, [assetTree, sortInfo])
const visibilities = React.useMemo(() => {
const map = new Map<backendModule.AssetId, Visibility>()
const processNode = (node: AssetTreeNode) => {
const processNode = (node: assetTreeNode.AnyAssetTreeNode) => {
let displayState = Visibility.hidden
const visible = filter?.(node) ?? true
for (const child of node.children ?? []) {
@ -639,7 +646,7 @@ export default function AssetsTable(props: AssetsTableProps) {
React.useEffect(() => {
const nodeToSuggestion = (
node: AssetTreeNode,
node: assetTreeNode.AnyAssetTreeNode,
key: assetQuery.AssetQueryKey = 'names'
): assetSearchBar.Suggestion => ({
render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`,
@ -932,7 +939,6 @@ export default function AssetsTable(props: AssetsTableProps) {
const rootParentDirectoryId = backendModule.DirectoryId('')
const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId)
const newRootNode = new AssetTreeNode(
rootDirectoryId,
rootDirectory,
rootParentDirectoryId,
rootParentDirectoryId,
@ -1058,7 +1064,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const doToggleDirectoryExpansion = React.useCallback(
(
directoryId: backendModule.DirectoryId,
key: backendModule.AssetId,
key: backendModule.DirectoryId,
title?: string | null,
override?: boolean
) => {
@ -1203,7 +1209,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const keys = selectedKeysRef.current
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
} else {
switch (item.item.type) {
switch (item.type) {
case backendModule.AssetType.directory: {
event.preventDefault()
event.stopPropagation()
@ -1254,7 +1260,7 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case 'ArrowLeft': {
if (item.item.type === backendModule.AssetType.directory) {
if (item.type === backendModule.AssetType.directory) {
if (item.children != null) {
// The folder is expanded; collapse it.
event.preventDefault()
@ -1280,7 +1286,7 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case 'ArrowRight': {
if (item.item.type === backendModule.AssetType.directory && item.children == null) {
if (item.type === backendModule.AssetType.directory && item.children == null) {
// The folder is collapsed; expand it.
event.preventDefault()
event.stopPropagation()
@ -1404,7 +1410,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const insertAssets = React.useCallback(
(
assets: backendModule.AnyAsset[],
parentKey: backendModule.AssetId | null,
parentKey: backendModule.DirectoryId | null,
parentId: backendModule.DirectoryId | null
) => {
const actualParentKey = parentKey ?? rootDirectoryId
@ -1423,7 +1429,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const insertArbitraryAssets = React.useCallback(
(
assets: backendModule.AnyAsset[],
parentKey: backendModule.AssetId | null,
parentKey: backendModule.DirectoryId | null,
parentId: backendModule.DirectoryId | null,
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
) => {
@ -1479,7 +1485,7 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case AssetListEventType.newProject: {
const projectName = getNewProjectName(event.templateName, event.parentId)
const projectName = getNewProjectName(event.preferredName, event.parentId)
const dummyId = backendModule.ProjectId(uniqueString.uniqueString())
const path =
backend instanceof LocalBackend ? backend.joinPath(event.parentId, projectName) : null
@ -1507,6 +1513,7 @@ export default function AssetsTable(props: AssetsTableProps) {
type: AssetEventType.newProject,
placeholderId: dummyId,
templateId: event.templateId,
datalinkId: event.datalinkId,
onSpinnerStateChange: event.onSpinnerStateChange,
})
break
@ -1798,7 +1805,7 @@ export default function AssetsTable(props: AssetsTableProps) {
])
const doPaste = React.useCallback(
(newParentKey: backendModule.AssetId, newParentId: backendModule.DirectoryId) => {
(newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => {
unsetModal()
if (pasteData != null) {
if (pasteData.data.has(newParentKey)) {

View File

@ -22,7 +22,7 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import * as backendModule from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import type * as pasteDataModule from '#/utilities/pasteData'
import * as permissions from '#/utilities/permissions'
import * as uniqueString from '#/utilities/uniqueString'
@ -38,14 +38,16 @@ export interface AssetsTableContextMenuProps {
readonly pasteData: pasteDataModule.PasteData<ReadonlySet<backendModule.AssetId>> | null
readonly selectedKeys: ReadonlySet<backendModule.AssetId>
readonly clearSelectedKeys: () => void
readonly nodeMapRef: React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>
readonly nodeMapRef: React.MutableRefObject<
ReadonlyMap<backendModule.AssetId, assetTreeNode.AnyAssetTreeNode>
>
readonly event: Pick<React.MouseEvent<Element, MouseEvent>, 'pageX' | 'pageY'>
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly doCopy: () => void
readonly doCut: () => void
readonly doPaste: (
newParentKey: backendModule.AssetId,
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId
) => void
}
@ -192,7 +194,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
selectedKeys.size === 1 && firstKey != null
? nodeMapRef.current.get(firstKey)
: null
if (selectedNode?.item.type === backendModule.AssetType.directory) {
if (selectedNode?.type === backendModule.AssetType.directory) {
doPaste(selectedNode.key, selectedNode.item.id)
} else {
doPaste(rootDirectoryId, rootDirectoryId)

View File

@ -201,7 +201,8 @@ export default function Drive(props: DriveProps) {
parentKey: targetDirectoryNodeRef.current?.key ?? rootDirectoryId,
parentId: targetDirectoryNodeRef.current?.item.id ?? rootDirectoryId,
templateId,
templateName,
datalinkId: null,
preferredName: templateName,
onSpinnerStateChange,
})
},

View File

@ -26,7 +26,7 @@ export interface GlobalContextMenuProps {
readonly directoryId: backendModule.DirectoryId | null
readonly dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
readonly doPaste: (
newParentKey: backendModule.AssetId,
newParentKey: backendModule.DirectoryId,
newParentId: backendModule.DirectoryId
) => void
}
@ -106,7 +106,8 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
parentKey: directoryKey ?? rootDirectoryId,
parentId: directoryId ?? rootDirectoryId,
templateId: null,
templateName: null,
datalinkId: null,
preferredName: null,
onSpinnerStateChange: null,
})
}}

View File

@ -417,7 +417,8 @@ export default function Dashboard(props: DashboardProps) {
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
templateId: templateId,
templateName: templateName,
datalinkId: null,
preferredName: templateName,
onSpinnerStateChange: onSpinnerStateChange,
})
},

View File

@ -951,8 +951,9 @@ export interface DeleteAssetRequestBody {
/** HTTP request body for the "create project" endpoint. */
export interface CreateProjectRequestBody {
readonly projectName: string
readonly projectTemplateName: string | null
readonly parentDirectoryId: DirectoryId | null
readonly projectTemplateName?: string
readonly parentDirectoryId?: DirectoryId
readonly datalinkId?: ConnectorId
}
/** HTTP request body for the "update project" endpoint.

View File

@ -437,6 +437,7 @@
"newFolderShortcut": "New Folder",
"newDataLinkShortcut": "New Data Link",
"newSecretShortcut": "New Secret",
"useInNewProjectShortcut": "Use In New Project",
"closeModalShortcut": "Close",
"cancelEditNameShortcut": "Cancel Editing",
"signInShortcut": "Login",

View File

@ -12,25 +12,36 @@ export interface AssetTreeNodeData
'children' | 'depth' | 'directoryId' | 'directoryKey' | 'item' | 'key'
> {}
/** All possible variants of {@link AssetTreeNode}s. */
// The `Item extends Item` is required to trigger distributive conditional types:
// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
export type AnyAssetTreeNode<Item extends backendModule.AnyAsset = backendModule.AnyAsset> =
Item extends Item ? AssetTreeNode<Item> : never
/** A node in the drive's item tree. */
export default class AssetTreeNode<Item extends backendModule.AnyAsset = backendModule.AnyAsset> {
readonly type: Item['type']
/** Create a {@link AssetTreeNode}. */
constructor(
/** The id of the asset (or the placeholder id for new assets). This must never change. */
public readonly key: Item['id'],
/** The actual asset. This MAY change if this is initially a placeholder item, but rows MAY
* keep updated values within the row itself as well. */
public item: Item,
/** The id of the asset's parent directory (or the placeholder id for new assets).
* This must never change. */
public readonly directoryKey: backendModule.AssetId,
public readonly directoryKey: backendModule.DirectoryId,
/** The actual id of the asset's parent directory (or the placeholder id for new assets). */
public readonly directoryId: backendModule.DirectoryId,
/** This is `null` if the asset is not a directory asset, OR if it is a collapsed directory
* asset. */
public readonly children: AssetTreeNode[] | null,
public readonly depth: number
) {}
public readonly children: AnyAssetTreeNode[] | null,
public readonly depth: number,
/** The internal (to the frontend) id of the asset (or the placeholder id for new assets).
* This must never change, otherwise the component's state is lost when receiving the real id
* from the backend. */
public readonly key: Item['id'] = item.id
) {
this.type = item.type
}
/** Get an {@link AssetTreeNode.key} from an {@link AssetTreeNode}. Useful for React,
* becausse references of static functions do not change. */
@ -45,16 +56,23 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
}
/** Creates an {@link AssetTreeNode} from a {@link backendModule.AnyAsset}. */
static fromAsset(
static fromAsset<Asset extends backendModule.AnyAsset>(
this: void,
asset: backendModule.AnyAsset,
directoryKey: backendModule.AssetId,
asset: Asset,
directoryKey: backendModule.DirectoryId,
directoryId: backendModule.DirectoryId,
depth: number,
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
): AssetTreeNode {
getKey ??= oldAsset => oldAsset.id
return new AssetTreeNode(getKey(asset), asset, directoryKey, directoryId, null, depth)
key: Asset['id'] = asset.id
): AnyAssetTreeNode {
return new AssetTreeNode(asset, directoryKey, directoryId, null, depth, key).asUnion()
}
/** Return `this`, coerced into an {@link AnyAssetTreeNode}. */
asUnion() {
// This is SAFE, as an `AssetTreeNode` cannot change types, and `AnyAssetTreeNode` is an
// exhaustive list of variants.
// eslint-disable-next-line no-restricted-syntax
return this as AnyAssetTreeNode
}
/** Whether this node contains a specific type of asset. */
@ -65,24 +83,25 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
}
/** Create a new {@link AssetTreeNode} with the specified properties updated. */
with(update: Partial<AssetTreeNodeData>) {
with(update: Partial<AssetTreeNodeData>): AnyAssetTreeNode {
return new AssetTreeNode(
update.key ?? this.key,
update.item ?? this.item,
update.directoryKey ?? this.directoryKey,
update.directoryId ?? this.directoryId,
// `null` MUST be special-cases in the following line.
// eslint-disable-next-line eqeqeq
update.children === null ? update.children : update.children ?? this.children,
update.depth ?? this.depth
)
update.depth ?? this.depth,
update.key ?? this.key
).asUnion()
}
/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. */
map(transform: (node: AssetTreeNode) => AssetTreeNode) {
map(transform: (node: AnyAssetTreeNode) => AnyAssetTreeNode): AnyAssetTreeNode {
const children = this.children ?? []
let result: AssetTreeNode = transform(this)
let result: AnyAssetTreeNode = transform(this.asUnion())
for (let i = 0; i < children.length; i += 1) {
const node = children[i]
if (node == null) {
@ -105,9 +124,9 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. The predicate is applied to
* a parent node before it is applied to its children. The root node is never removed. */
filter(predicate: (node: AssetTreeNode) => boolean) {
filter(predicate: (node: AnyAssetTreeNode) => boolean): AnyAssetTreeNode {
const children = this.children ?? []
let result: AssetTreeNode | null = null
let result: AnyAssetTreeNode | null = null
for (let i = 0; i < children.length; i += 1) {
const node = children[i]
if (node == null) {
@ -132,13 +151,15 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
}
}
}
return result?.children?.length === 0 ? result.with({ children: null }) : result ?? this
return result?.children?.length === 0
? result.with({ children: null })
: result ?? this.asUnion()
}
/** Returns all items in the tree, flattened into an array using pre-order traversal. */
preorderTraversal(
preprocess: ((tree: AssetTreeNode[]) => AssetTreeNode[]) | null = null
): AssetTreeNode[] {
preprocess: ((tree: AnyAssetTreeNode[]) => AnyAssetTreeNode[]) | null = null
): AnyAssetTreeNode[] {
return (preprocess?.(this.children ?? []) ?? this.children ?? []).flatMap(node =>
node.children == null ? [node] : [node, ...node.preorderTraversal(preprocess)]
)