mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 11:52:59 +03:00
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:
parent
65737b34f5
commit
c94507dd3b
5
app/ide-desktop/lib/assets/compare.svg
Normal file
5
app/ide-desktop/lib/assets/compare.svg
Normal 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 |
@ -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 |
7
app/ide-desktop/lib/assets/restore.svg
Normal file
7
app/ide-desktop/lib/assets/restore.svg
Normal 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 |
@ -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' },
|
||||
|
@ -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, {
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -573,6 +573,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
category={Category.home}
|
||||
labels={labels}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
dispatchAssetListEvent={dispatchAssetListEvent}
|
||||
isReadonly={category === Category.trash}
|
||||
/>
|
||||
)}
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
|
@ -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> {
|
||||
|
@ -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`
|
||||
|
@ -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",
|
||||
|
||||
|
@ -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]
|
||||
|
Loading…
Reference in New Issue
Block a user