Duplicate and restore project (#9816)

- Close https://github.com/enso-org/cloud-v2/issues/943
- Add buttons in "versions" tab on the right hand side panel to restore and duplicate projects.

# Important Notes
- There is an UI issue when restoring a project - the placeholder UI is removed one render tick later than the "list versions" query is refetched, so an extra version appears for one frame.
This commit is contained in:
somebody1234 2024-05-30 19:30:42 +10:00 committed by GitHub
parent 65737b34f5
commit c94507dd3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 382 additions and 90 deletions

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.45" x="2" width="12" height="16" rx="2" fill="black" />
<path d="M7 5H5V7H7V9H9V7H11V5H9V3H7V5Z" fill="black" />
<rect x="5" y="11" width="6" height="2" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -1,16 +1,20 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_22795_32405)">
<path d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18Z" fill="#3E515F" fill-opacity="0.0898039"/>
<g opacity="0.6">
<path d="M9.39065 2.27937L8.32999 3.34003L10.8049 5.8149L11.8655 4.75424L9.39065 2.27937Z" fill="#3E515F"/>
<path d="M10.8049 3.68513L8.32999 6.16L9.39065 7.22066L11.8655 4.74579L10.8049 3.68513Z" fill="#3E515F"/>
<path d="M14 9C14 9.98891 13.7068 10.9556 13.1573 11.7779C12.6079 12.6001 11.827 13.241 10.9134 13.6194C9.99979 13.9978 8.99445 14.0969 8.02455 13.9039C7.05464 13.711 6.16373 13.2348 5.46447 12.5355C4.7652 11.8363 4.289 10.9454 4.09607 9.97545C3.90315 9.00555 4.00216 8.00021 4.3806 7.08658C4.75904 6.17295 5.3999 5.39206 6.22215 4.84265C7.04439 4.29324 8.01109 4 9 4L9 5.5C8.30777 5.5 7.63108 5.70527 7.0555 6.08986C6.47993 6.47444 6.03133 7.02107 5.76642 7.66061C5.50151 8.30015 5.4322 9.00388 5.56725 9.68282C5.7023 10.3617 6.03564 10.9854 6.52513 11.4749C7.01461 11.9644 7.63825 12.2977 8.31718 12.4327C8.99612 12.5678 9.69985 12.4985 10.3394 12.2336C10.9789 11.9687 11.5256 11.5201 11.9101 10.9445C12.2947 10.3689 12.5 9.69223 12.5 9H14Z" fill="#3E515F"/>
<rect x="9" y="4" width="1" height="2" fill="#3E515F"/>
</g>
</g>
<defs>
<clipPath id="clip0_22795_32405">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>
<g clip-path="url(#clip0_22795_32405)">
<path
d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18Z"
fill="#3E515F" fill-opacity="0.0898039" />
<g opacity="0.6">
<path d="M3.39065 7.79933L2.32999 8.85999L4.80486 11.3349L5.86552 10.2742L3.39065 7.79933Z" fill="#3E515F" />
<path d="M6.22487 7.79512L3.75 10.27L4.81066 11.3306L7.28553 8.85578L6.22487 7.79512Z" fill="#3E515F" />
<path
d="M4 9C4 8.01109 4.29325 7.04439 4.84265 6.22215C5.39206 5.3999 6.17295 4.75904 7.08658 4.3806C8.00021 4.00216 9.00555 3.90315 9.97545 4.09607C10.9454 4.289 11.8363 4.7652 12.5355 5.46447C13.2348 6.16373 13.711 7.05464 13.9039 8.02455C14.0969 8.99445 13.9978 9.99979 13.6194 10.9134C13.241 11.827 12.6001 12.6079 11.7778 13.1573C10.9556 13.7068 9.9889 14 9 14L9 12.5C9.69223 12.5 10.3689 12.2947 10.9445 11.9101C11.5201 11.5256 11.9687 10.9789 12.2336 10.3394C12.4985 9.69985 12.5678 8.99612 12.4327 8.31718C12.2977 7.63825 11.9644 7.01461 11.4749 6.52513C10.9854 6.03564 10.3618 5.7023 9.68282 5.56725C9.00388 5.4322 8.30015 5.50151 7.66061 5.76642C7.02107 6.03133 6.47444 6.47993 6.08986 7.0555C5.70527 7.63108 5.5 8.30777 5.5 9L4 9Z"
fill="#3E515F" />
<rect x="4" y="9" width="2" height="1" fill="#3E515F" />
</g>
</g>
<defs>
<clipPath id="clip0_22795_32405">
<rect width="18" height="18" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.45" x="2" width="12" height="16" rx="2" fill="black" />
<path d="M6.1 8L4.6 9.5L3.1 8L6.1 8Z" fill="black" />
<path
d="M4 8C4 7.20887 4.2346 6.43552 4.67412 5.77772C5.11365 5.11992 5.73836 4.60723 6.46927 4.30448C7.20017 4.00173 8.00444 3.92252 8.78036 4.07686C9.55629 4.2312 10.269 4.61216 10.8284 5.17157C11.3878 5.73098 11.7688 6.44372 11.9231 7.21964C12.0775 7.99556 11.9983 8.79983 11.6955 9.53073C11.3928 10.2616 10.8801 10.8864 10.2223 11.3259C9.56448 11.7654 8.79112 12 8 12L8 10.8C8.55379 10.8 9.09514 10.6358 9.5556 10.3281C10.0161 10.0204 10.3749 9.58315 10.5869 9.07151C10.7988 8.55988 10.8542 7.99689 10.7462 7.45375C10.6382 6.9106 10.3715 6.41169 9.9799 6.0201C9.58831 5.62851 9.0894 5.36184 8.54625 5.2538C8.00311 5.14576 7.44012 5.20121 6.92849 5.41314C6.41685 5.62506 5.97955 5.98395 5.67189 6.4444C5.36422 6.90486 5.2 7.44621 5.2 8L4 8Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 1005 B

View File

@ -23,7 +23,7 @@ const OVERLAY_STYLES = twv.tv({
})
const MODAL_STYLES = twv.tv({
base: 'fixed inset-0 flex items-center justify-center text-center p-4',
base: 'fixed inset-0 flex items-center justify-center text-center text-xs text-primary p-4',
variants: {
isEntering: { true: 'animate-in slide-in-from-top-1 ease-out duration-200' },
isExiting: { true: 'animate-out slide-out-to-top-1 ease-in duration-200' },

View File

@ -141,12 +141,17 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
if (asset.id === event.placeholderId) {
rowState.setVisibility(Visibility.faded)
try {
const createdProject = await backend.createProject({
parentDirectoryId: asset.parentId,
projectName: asset.title,
...(event.templateId == null ? {} : { projectTemplateName: event.templateId }),
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
})
const createdProject =
event.originalId == null || event.versionId == null
? await backend.createProject({
parentDirectoryId: asset.parentId,
projectName: asset.title,
...(event.templateId == null
? {}
: { projectTemplateName: event.templateId }),
...(event.datalinkId == null ? {} : { datalinkId: event.datalinkId }),
})
: await backend.duplicateProject(event.originalId, event.versionId, asset.title)
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {

View File

@ -8,6 +8,7 @@ enum AssetListEventType {
newDatalink = 'new-datalink',
newSecret = 'new-secret',
insertAssets = 'insert-assets',
duplicateProject = 'duplicate-project',
closeFolder = 'close-folder',
copy = 'copy',
move = 'move',

View File

@ -64,6 +64,8 @@ export interface AssetNewProjectEvent extends AssetBaseEvent<AssetEventType.newP
readonly placeholderId: backend.ProjectId
readonly templateId: string | null
readonly datalinkId: backend.DatalinkId | null
readonly originalId: backend.ProjectId | null
readonly versionId: backend.S3ObjectVersionId | null
readonly onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}

View File

@ -31,6 +31,7 @@ interface AssetListEvents {
readonly newSecret: AssetListNewSecretEvent
readonly newDatalink: AssetListNewDatalinkEvent
readonly insertAssets: AssetListInsertAssetsEvent
readonly duplicateProject: AssetListDuplicateProjectEvent
readonly closeFolder: AssetListCloseFolderEvent
readonly copy: AssetListCopyEvent
readonly move: AssetListMoveEvent
@ -97,6 +98,15 @@ interface AssetListInsertAssetsEvent extends AssetListBaseEvent<AssetListEventTy
readonly assets: backend.AnyAsset[]
}
/** A signal to duplicate a project. */
interface AssetListDuplicateProjectEvent
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly original: backend.ProjectAsset
readonly versionId: backend.S3ObjectVersionId
}
/** A signal to close (collapse) a folder. */
interface AssetListCloseFolderEvent extends AssetListBaseEvent<AssetListEventType.closeFolder> {
readonly id: backend.DirectoryId

View File

@ -1,5 +1,5 @@
/** @file Diff view comparing `Main.enso` of two versions for a specific project. */
import * as react from '@monaco-editor/react'
import * as monacoReact from '@monaco-editor/react'
import * as textProvider from '#/providers/TextProvider'
@ -50,7 +50,7 @@ export function AssetDiffView(props: AssetDiffViewProps) {
return loader
} else {
return (
<react.DiffEditor
<monacoReact.DiffEditor
beforeMount={monaco => {
monaco.editor.defineTheme('myTheme', {
base: 'vs',

View File

@ -5,6 +5,7 @@ import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetProperties from '#/layouts/AssetProperties'
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
@ -62,19 +63,13 @@ export interface AssetPanelProps extends AssetPanelRequiredProps {
readonly category: Category
readonly labels: backend.Label[]
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
}
/** A panel containing the description and settings for an asset. */
export default function AssetPanel(props: AssetPanelProps) {
const {
item,
setItem,
setQuery,
category,
labels,
dispatchAssetEvent,
isReadonly = false,
} = props
const { item, isReadonly = false, setItem, setQuery, category, labels } = props
const { dispatchAssetEvent, dispatchAssetListEvent } = props
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
@ -153,7 +148,9 @@ export default function AssetPanel(props: AssetPanelProps) {
dispatchAssetEvent={dispatchAssetEvent}
/>
)}
{tab === AssetPanelTab.versions && <AssetVersions item={item} />}
{tab === AssetPanelTab.versions && (
<AssetVersions item={item} dispatchAssetListEvent={dispatchAssetListEvent} />
)}
</>
)}
</div>

View File

@ -1,39 +1,66 @@
/** @file Displays information describing a specific version of an asset. */
import Duplicate from 'enso-assets/duplicate.svg'
import * as React from 'react'
import CompareIcon from 'enso-assets/compare.svg'
import DuplicateIcon from 'enso-assets/duplicate.svg'
import RestoreIcon from 'enso-assets/restore.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import * as assetDiffView from '#/layouts/AssetDiffView'
import * as ariaComponents from '#/components/AriaComponents'
import ButtonRow from '#/components/styled/ButtonRow'
import type Backend from '#/services/Backend'
import * as backendService from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as assetDiffView from './AssetDiffView'
// ====================
// === AssetVersion ===
// ====================
/** Props for a {@link AssetVersion}. */
export interface AssetVersionProps {
readonly item: backendService.AnyAsset
readonly placeholder?: boolean
readonly item: AssetTreeNode
readonly number: number
readonly version: backendService.S3ObjectVersion
readonly latestVersion: backendService.S3ObjectVersion
readonly backend: Backend
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly doRestore: () => Promise<void> | void
}
/** Displays information describing a specific version of an asset. */
export default function AssetVersion(props: AssetVersionProps) {
const { number, version, item, backend, latestVersion } = props
const { placeholder = false, number, version, item, backend, latestVersion } = props
const { dispatchAssetListEvent, doRestore } = props
const { getText } = textProvider.useText()
const asset = item.item
const isProject = asset.type === backendService.AssetType.project
const isProject = item.type === backendService.AssetType.project
const doDuplicate = () => {
if (isProject) {
dispatchAssetListEvent({
type: AssetListEventType.duplicateProject,
parentKey: item.directoryKey,
parentId: asset.parentId,
original: asset,
versionId: version.versionId,
})
}
}
return (
<div className="flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2">
<div
className={`flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2 ${placeholder ? 'opacity-50' : ''}`}
>
<div className="flex flex-1 flex-col">
<div>
{getText('versionX', number)} {version.isLatest && getText('latestIndicator')}
@ -51,8 +78,8 @@ export default function AssetVersion(props: AssetVersionProps) {
<ariaComponents.Button
variant="icon"
aria-label={getText('compareWithLatest')}
icon={Duplicate}
isDisabled={version.isLatest}
icon={CompareIcon}
isDisabled={version.isLatest || placeholder}
/>
<ariaComponents.Tooltip>{getText('compareWithLatest')}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
@ -60,15 +87,75 @@ export default function AssetVersion(props: AssetVersionProps) {
type="fullscreen"
title={getText('compareVersionXWithLatest', number)}
>
<assetDiffView.AssetDiffView
latestVersionId={latestVersion.versionId}
versionId={version.versionId}
project={item}
backend={backend}
/>
{opts => (
<div className="flex h-full flex-col gap-3">
<ButtonRow>
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
variant="icon"
aria-label={getText('restoreThisVersion')}
icon={RestoreIcon}
isDisabled={version.isLatest || placeholder}
onPress={async () => {
await doRestore()
opts.close()
}}
/>
<ariaComponents.Tooltip>
{getText('restoreThisVersion')}
</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
variant="icon"
aria-label={getText('duplicateThisVersion')}
icon={DuplicateIcon}
isDisabled={placeholder}
onPress={() => {
doDuplicate()
opts.close()
}}
/>
<ariaComponents.Tooltip>
{getText('duplicateThisVersion')}
</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
</ButtonRow>
<assetDiffView.AssetDiffView
latestVersionId={latestVersion.versionId}
versionId={version.versionId}
project={asset}
backend={backend}
/>
</div>
)}
</ariaComponents.Dialog>
</ariaComponents.DialogTrigger>
)}
{isProject && (
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
variant="icon"
aria-label={getText('restoreThisVersion')}
icon={RestoreIcon}
isDisabled={version.isLatest || placeholder}
onPress={doRestore}
/>
<ariaComponents.Tooltip>{getText('restoreThisVersion')}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
)}
{isProject && (
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
variant="icon"
aria-label={getText('duplicateThisVersion')}
icon={DuplicateIcon}
isDisabled={placeholder}
onPress={doDuplicate}
/>
<ariaComponents.Tooltip>{getText('duplicateThisVersion')}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
)}
</div>
</div>
)

View File

@ -1,11 +1,15 @@
/** @file A list of previous versions of an asset. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetListEvent from '#/events/assetListEvent'
import AssetVersion from '#/layouts/AssetVersion'
import * as useAssetVersions from '#/layouts/AssetVersions/useAssetVersions'
@ -15,6 +19,18 @@ import * as spinnerModule from '#/components/Spinner'
import * as backendService from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as uniqueString from '#/utilities/uniqueString'
// ==============================
// === AddNewVersionVariables ===
// ==============================
/** Variables for the "add new version" mutation. */
interface AddNewVersionVariables {
readonly versionId: backendService.S3ObjectVersionId
readonly placeholderId: backendService.S3ObjectVersionId
}
// =====================
// === AssetVersions ===
@ -23,56 +39,108 @@ import type AssetTreeNode from '#/utilities/AssetTreeNode'
/** Props for a {@link AssetVersions}. */
export interface AssetVersionsProps {
readonly item: AssetTreeNode
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
}
/** A list of previous versions of an asset. */
export default function AssetVersions(props: AssetVersionsProps) {
const { item } = props
const { item, dispatchAssetListEvent } = props
const { backend } = backendProvider.useStrictBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [placeholderVersions, setPlaceholderVersions] = React.useState<
readonly backendService.S3ObjectVersion[]
>([])
const isCloud = backend.type === backendService.BackendType.remote
const {
status,
data: versions,
isPending,
} = useAssetVersions.useAssetVersions({
const queryKey = ['assetVersions', item.item.id, item.item.title]
const versionsQuery = useAssetVersions.useAssetVersions({
backend,
queryKey,
assetId: item.item.id,
title: item.item.title,
onError: backendError => toastAndLog('listVersionsError', backendError),
enabled: isCloud,
})
const latestVersion = versionsQuery.data?.find(version => version.isLatest)
const latestVersion = versions?.find(version => version.isLatest)
const restoreMutation = reactQuery.useMutation({
mutationFn: async (variables: AddNewVersionVariables) => {
if (item.item.type === backendService.AssetType.project) {
await backend.restoreProject(item.item.id, variables.versionId, item.item.title)
}
},
onMutate: variables => {
setPlaceholderVersions(oldVersions => [
{
isLatest: false,
key: uniqueString.uniqueString(),
lastModified: dateTime.toRfc3339(new Date()),
versionId: variables.placeholderId,
},
...oldVersions,
])
},
onSuccess: async () => {
// `backend.restoreProject` does not return the ID of the new version, so a full refetch is
// necessary.
await versionsQuery.refetch()
},
onError: (error: unknown) => {
toastAndLog('restoreProjectError', error, item.item.title)
},
onSettled: (_data, _error, variables) => {
setPlaceholderVersions(oldVersions =>
oldVersions.filter(version => version.versionId !== variables.placeholderId)
)
},
})
return (
<div className="pointer-events-auto flex flex-1 shrink-0 flex-col items-center overflow-y-auto overflow-x-hidden">
{(() => {
if (!isCloud) {
return <div>{getText('localAssetsDoNotHaveVersions')}</div>
} else if (isPending) {
return <Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
} else if (status === 'error') {
return <div>{getText('listVersionsError')}</div>
} else if (versions.length === 0) {
return <div>{getText('noVersionsFound')}</div>
} else if (!latestVersion) {
return <div>{getText('fetchLatestVersionError')}</div>
} else {
return versions.map((version, i) => (
{!isCloud ? (
<div>{getText('localAssetsDoNotHaveVersions')}</div>
) : versionsQuery.isPending ? (
<Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
) : versionsQuery.isError ? (
<div>{getText('listVersionsError')}</div>
) : versionsQuery.data.length === 0 ? (
<div>{getText('noVersionsFound')}</div>
) : latestVersion == null ? (
<div>{getText('fetchLatestVersionError')}</div>
) : (
[
...placeholderVersions.map((version, i) => (
<AssetVersion
key={version.versionId}
number={versions.length - i}
placeholder
number={versionsQuery.data.length + placeholderVersions.length - i}
version={version}
item={item.item}
item={item}
backend={backend}
latestVersion={latestVersion}
dispatchAssetListEvent={dispatchAssetListEvent}
doRestore={() => {}}
/>
))
}
})()}
)),
...versionsQuery.data.map((version, i) => (
<AssetVersion
key={version.versionId}
number={versionsQuery.data.length - i}
version={version}
item={item}
backend={backend}
latestVersion={latestVersion}
dispatchAssetListEvent={dispatchAssetListEvent}
doRestore={() =>
restoreMutation.mutateAsync({
versionId: version.versionId,
placeholderId: backendService.S3ObjectVersionId(uniqueString.uniqueString()),
})
}
/>
)),
]
)}
</div>
)
}

View File

@ -7,9 +7,7 @@ import * as reactQuery from '@tanstack/react-query'
import type Backend from '#/services/Backend'
import type * as backendService from '#/services/Backend'
/**
* Parameters for the useAssetVersions hook
*/
/** Parameters for the {@link useAssetVersions} hook. */
export interface UseAssetVersionsParams {
readonly assetId: backendService.AssetId
readonly title: string
@ -19,18 +17,10 @@ export interface UseAssetVersionsParams {
readonly onError?: (error: unknown) => void
}
/**
* Fetches the versions of the selected project asset
*/
/** Fetches the versions of the selected project asset. */
export function useAssetVersions(params: UseAssetVersionsParams) {
const {
enabled = true,
title,
assetId,
backend,
onError,
queryKey = ['assetVersions', assetId, title],
} = params
const { enabled = true, title, assetId, backend, onError } = params
const { queryKey = ['assetVersions', assetId, title] } = params
return reactQuery.useQuery({
queryKey,

View File

@ -1515,6 +1515,8 @@ export default function AssetsTable(props: AssetsTableProps) {
placeholderId: dummyId,
templateId: event.templateId,
datalinkId: event.datalinkId,
originalId: null,
versionId: null,
onSpinnerStateChange: event.onSpinnerStateChange,
})
break
@ -1699,6 +1701,45 @@ export default function AssetsTable(props: AssetsTableProps) {
insertArbitraryAssets(event.assets, event.parentKey, event.parentId)
break
}
case AssetListEventType.duplicateProject: {
const siblings = nodeMapRef.current.get(event.parentKey)?.children ?? []
const siblingTitles = new Set(siblings.map(sibling => sibling.item.title))
let index = 1
let title = `${event.original.title} (${index})`
while (siblingTitles.has(title)) {
index += 1
title = `${event.original.title} (${index})`
}
const placeholderItem: backendModule.ProjectAsset = {
type: backendModule.AssetType.project,
id: backendModule.ProjectId(uniqueString.uniqueString()),
title,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: event.parentId,
permissions: permissions.tryGetSingletonOwnerPermission(user),
projectState: {
type: backendModule.ProjectState.placeholder,
volumeId: '',
...(user != null ? { openedBy: user.email } : {}),
...(event.original.projectState.path != null
? { path: event.original.projectState.path }
: {}),
},
labels: [],
description: null,
}
insertAssets([placeholderItem], event.parentKey, event.parentId)
dispatchAssetEvent({
type: AssetEventType.newProject,
placeholderId: placeholderItem.id,
templateId: null,
datalinkId: null,
originalId: event.original.id,
versionId: event.versionId,
onSpinnerStateChange: null,
})
break
}
case AssetListEventType.willDelete: {
if (selectedKeysRef.current.has(event.key)) {
const newSelectedKeys = new Set(selectedKeysRef.current)

View File

@ -573,6 +573,7 @@ export default function Dashboard(props: DashboardProps) {
category={Category.home}
labels={labels}
dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent}
isReadonly={category === Category.trash}
/>
)}

View File

@ -55,6 +55,10 @@ export const SecretId = newtype.newtypeConstructor<SecretId>()
export type DatalinkId = newtype.Newtype<string, 'DatalinkId'>
export const DatalinkId = newtype.newtypeConstructor<DatalinkId>()
/** Unique identifier for a version of an S3 object. */
export type S3ObjectVersionId = newtype.Newtype<string, 'S3ObjectVersionId'>
export const S3ObjectVersionId = newtype.newtypeConstructor<S3ObjectVersionId>()
/** Unique identifier for an arbitrary asset. */
export type AssetId = IdType[keyof IdType]
@ -913,7 +917,7 @@ export const assetIsFile = assetIsType(AssetType.file)
/** Metadata describing a specific version of an asset. */
export interface S3ObjectVersion {
readonly versionId: string
readonly versionId: S3ObjectVersionId
readonly lastModified: dateTime.Rfc3339DateTime
readonly isLatest: boolean
/** An archive containing the all the project files object in the S3 bucket. */
@ -1312,6 +1316,18 @@ export default abstract class Backend {
abstract createProject(body: CreateProjectRequestBody): Promise<CreatedProject>
/** Close a project. */
abstract closeProject(projectId: ProjectId, title: string): Promise<void>
/** Restore a project from a different version. */
abstract restoreProject(
projectId: ProjectId,
versionId: S3ObjectVersionId,
title: string
): Promise<void>
/** Duplicate a specific version of a project. */
abstract duplicateProject(
projectId: ProjectId,
versionId: S3ObjectVersionId,
title: string
): Promise<CreatedProject>
/** Return project details. */
abstract getProjectDetails(
projectId: ProjectId,

View File

@ -595,6 +595,16 @@ export default class LocalBackend extends Backend {
return this.invalidOperation()
}
/** Invalid operation. */
override duplicateProject() {
return this.invalidOperation()
}
/** Invalid operation. */
override restoreProject() {
return this.invalidOperation()
}
/** Invalid operation. */
override listAssetVersions() {
return this.invalidOperation()

View File

@ -631,6 +631,37 @@ export default class RemoteBackend extends Backend {
}
}
/** Restore a project from a different version. */
override async restoreProject(
projectId: backend.ProjectId,
versionId: backend.S3ObjectVersionId,
title: string
): Promise<void> {
const path = remoteBackendPaths.restoreProjectPath(projectId)
const response = await this.post(path, { versionId })
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'restoreProjectBackendError', title)
} else {
return
}
}
/** Duplicate a specific version of a project. */
override async duplicateProject(
projectId: backend.ProjectId,
versionId: backend.S3ObjectVersionId,
title: string
): Promise<backend.CreatedProject> {
const path = remoteBackendPaths.duplicateProjectPath(projectId)
const response = await this.post<backend.CreatedProject>(path, { versionId })
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'duplicateProjectBackendError', title)
} else {
const json = await response.json()
return json
}
}
/** Close a project.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async closeProject(projectId: backend.ProjectId, title: string): Promise<void> {

View File

@ -107,6 +107,14 @@ export function closeProjectPath(projectId: backend.ProjectId) {
export function getProjectDetailsPath(projectId: backend.ProjectId) {
return `projects/${projectId}`
}
/** Relative HTTP path to the "duplicate project" endpoint of the Cloud backend API. */
export function duplicateProjectPath(projectId: backend.ProjectId) {
return `projects/${projectId}/versions/clone`
}
/** Relative HTTP path to the "restore project" endpoint of the Cloud backend API. */
export function restoreProjectPath(projectId: backend.ProjectId) {
return `projects/${projectId}/versions/restore`
}
/** Relative HTTP path to the "open project" endpoint of the Cloud backend API. */
export function openProjectPath(projectId: backend.ProjectId) {
return `projects/${projectId}/open`

View File

@ -10,6 +10,7 @@
"updateProjectError": "Could not update project",
"findProjectError": "Could not find project '$0'",
"openProjectError": "Could not open project '$0'",
"restoreProjectError": "Could not restore project '$0'",
"copyAssetError": "Could not copy '$0'",
"moveAssetError": "Could not move '$0'",
"deleteAssetError": "Could not delete '$0'",
@ -100,6 +101,8 @@
"copyAssetBackendError": "Could not copy '$0' to '$1'.",
"listProjectsBackendError": "Could not list projects.",
"createProjectBackendError": "Could not create project with name '$0'",
"restoreProjectBackendError": "Could not restore project '$0'",
"duplicateProjectBackendError": "Could not duplicate project as '$0'",
"closeProjectBackendError": "Could not close project '$0'.",
"getProjectDetailsBackendError": "Could not get details of project '$0'.",
"openProjectBackendError": "Could not open project '$0'.",
@ -601,6 +604,9 @@
"userGroupContextMenuLabel": "User Group context menu",
"userGroupUserContextMenuLabel": "User Group User context menu",
"restoreThisVersion": "Restore this version",
"duplicateThisVersion": "Duplicate this version",
"appNameDesktopEdition": "Enso Desktop Edition",
"appNameCloudEdition": "Enso Cloud Edition",

View File

@ -28,6 +28,7 @@ interface PlaceholderOverrides {
readonly openProjectError: [projectName: string]
readonly deleteAssetError: [assetName: string]
readonly restoreAssetError: [assetName: string]
readonly restoreProjectError: [projectName: string]
readonly unknownThreadIdError: [threadId: string]
readonly needsOwnerError: [assetType: string]
readonly inviteSuccess: [userEmail: string]
@ -81,6 +82,8 @@ interface PlaceholderOverrides {
readonly undoDeleteAssetBackendError: [string]
readonly copyAssetBackendError: [string, string]
readonly createProjectBackendError: [string]
readonly restoreProjectBackendError: [string]
readonly duplicateProjectBackendError: [string]
readonly closeProjectBackendError: [string]
readonly getProjectDetailsBackendError: [string]
readonly openProjectBackendError: [string]