diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5e023d18db..5c83b1cfb59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Next Next Release +# Next Release #### Enso IDE @@ -6,9 +6,13 @@ GeoMap visualization][11889]. - [Round ‘Add component’ button under the component menu replaced by a small button protruding from the output port.][11836]. +- [Fixed nodes being selected after deleting other nodes or connections.][11902] +- [Redo stack is no longer lost when interacting with text literals][11908]. [11889]: https://github.com/enso-org/enso/pull/11889 [11836]: https://github.com/enso-org/enso/pull/11836 +[11902]: https://github.com/enso-org/enso/pull/11902 +[11908]: https://github.com/enso-org/enso/pull/11908 #### Enso Language & Runtime @@ -24,7 +28,7 @@ [11856]: https://github.com/enso-org/enso/pull/11856 [11897]: https://github.com/enso-org/enso/pull/11897 -# Next Release +# Enso 2024.5 #### Enso IDE diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 15e01e9a15fd..452d08ef633e 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1290,7 +1290,8 @@ export interface CreateSecretRequestBody { /** HTTP request body for the "update secret" endpoint. */ export interface UpdateSecretRequestBody { - readonly value: string + readonly title: string | null + readonly value: string | null } /** HTTP request body for the "create datalink" endpoint. */ diff --git a/app/common/src/text.ts b/app/common/src/text.ts index f7b295daf2df..2446d8405d78 100644 --- a/app/common/src/text.ts +++ b/app/common/src/text.ts @@ -3,10 +3,6 @@ import ENGLISH from './text/english.json' with { type: 'json' } import { unsafeKeys } from './utilities/data/object' -// ============= -// === Types === -// ============= - /** Possible languages in which to display text. */ export enum Language { english = 'english', @@ -46,6 +42,7 @@ interface PlaceholderOverrides { readonly confirmPrompt: [action: string] readonly trashTheAssetTypeTitle: [assetType: string, assetName: string] readonly deleteTheAssetTypeTitle: [assetType: string, assetName: string] + readonly deleteTheAssetTypeTitleForever: [assetType: string, assetName: string] readonly couldNotInviteUser: [userEmail: string] readonly filesWithoutConflicts: [fileCount: number] readonly projectsWithoutConflicts: [projectCount: number] diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 35c1aea3cf49..2a8daff68ada 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -411,6 +411,7 @@ "thisFolderFailedToFetch": "This folder failed to fetch.", "yourTrashIsEmpty": "Your trash is empty.", "deleteTheAssetTypeTitle": "delete the $0 '$1'", + "deleteTheAssetTypeTitleForever": "permanently delete the $0 '$1'", "trashTheAssetTypeTitle": "move the $0 '$1' to Trash", "notImplemetedYet": "Not implemented yet.", "newLabelButtonLabel": "New label", @@ -456,7 +457,8 @@ "youHaveNoRecentProjects": "You have no recent projects. Switch to another category to create a project.", "youHaveNoFiles": "This folder is empty. You can create a project using the buttons above.", "placeholderChatPrompt": "Login or register to access live chat with our support team.", - "confirmPrompt": "Are you sure you want to $0?", + "confirmPrompt": "Do you really want to $0?", + "thisOperationCannotBeUndone": "This operation is final and cannot be undone.", "couldNotInviteUser": "Could not invite user $0", "inviteFormSeatsLeft": "You have $0 seats left on your plan. Upgrade to invite more", "inviteFormSeatsLeftError": "You have exceed the number of seats on your plan by $0", diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 56cfa3562134..77c71501dc22 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -1,10 +1,10 @@ /** @file The base class from which all `Actions` classes are derived. */ import { expect, test, type Locator, type Page } from '@playwright/test' -import type { AutocompleteKeybind } from '#/utilities/inputBindings' +import type { AutocompleteKeybind, ModifierKey } from '#/utilities/inputBindings' /** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ -async function modModifier(page: Page) { +export async function modModifier(page: Page) { let userAgent = '' await test.step('Detect browser OS', async () => { userAgent = await page.evaluate(() => navigator.userAgent) @@ -51,11 +51,17 @@ export default class BaseActions implements Promise { } /** - * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` - * on all other platforms. + * Return the appropriate key for a shortcut, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, + * and `Control` on all other platforms. Similarly, replace the text `Delete` with `Backspace` + * on `macOS`, and `Delete` on all other platforms. */ - static press(page: Page, keyOrShortcut: string): Promise { - return test.step(`Press '${keyOrShortcut}'`, async () => { + static async withNormalizedKey( + page: Page, + keyOrShortcut: string, + callback: (shortcut: string) => Promise, + description = 'Normalize', + ): Promise { + return test.step(`${description} '${keyOrShortcut}'`, async () => { if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { let userAgent = '' await test.step('Detect browser OS', async () => { @@ -65,13 +71,23 @@ export default class BaseActions implements Promise { const ctrlKey = isMacOS ? 'Meta' : 'Control' const deleteKey = isMacOS ? 'Backspace' : 'Delete' const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey) - await page.keyboard.press(shortcut) + return await callback(shortcut) } else { - await page.keyboard.press(keyOrShortcut) + return callback(keyOrShortcut) } }) } + /** Press a key or shortcut. */ + static async press(page: Page, keyOrShortcut: string) { + await BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.press(shortcut), + 'Press and release', + ) + } + /** Proxies the `then` method of the internal {@link Promise}. */ async then( onfulfilled?: (() => PromiseLike | T) | null | undefined, @@ -135,8 +151,45 @@ export default class BaseActions implements Promise { * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` * on all other platforms. */ - press(keyOrShortcut: AutocompleteKeybind) { - return this.do((page) => BaseActions.press(page, keyOrShortcut)) + press(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.press(shortcut), + 'Press and release', + ), + ) + } + + /** + * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` + * on all other platforms. + */ + down(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.down(shortcut), + 'Press', + ), + ) + } + + /** + * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` + * on all other platforms. + */ + up(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.up(shortcut), + 'Release', + ), + ) } /** Perform actions until a predicate passes. */ diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 0ecec073dc50..b31d0a05be85 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -375,6 +375,14 @@ export default class DrivePageActions extends PageActions { }) } + /** Clear trash. */ + clearTrash() { + return this.step('Clear trash', async (page) => { + await page.getByText(TEXT.clearTrash).click() + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + }) + } + /** Create a new empty project. */ newEmptyProject() { return this.step( diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 980a76c3cef3..8f12be78c4a3 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -103,7 +103,7 @@ const INITIAL_CALLS_OBJECT = { updateDirectory: array< { directoryId: backend.DirectoryId } & backend.UpdateDirectoryRequestBody >(), - deleteAsset: array<{ assetId: backend.AssetId }>(), + deleteAsset: array<{ assetId: backend.AssetId; force: boolean }>(), undoDeleteAsset: array<{ assetId: backend.AssetId }>(), createUser: array(), createUserGroup: array(), @@ -283,6 +283,17 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return !alreadyDeleted } + const forceDeleteAsset = (assetId: backend.AssetId) => { + const hasAsset = assetMap.has(assetId) + deletedAssets.delete(assetId) + assetMap.delete(assetId) + assets.splice( + assets.findIndex((asset) => asset.id === assetId), + 1, + ) + return hasAsset + } + const undeleteAsset = (assetId: backend.AssetId) => { const wasDeleted = deletedAssets.has(assetId) deletedAssets.delete(assetId) @@ -487,7 +498,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, - permissions: [], + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, @@ -517,6 +528,48 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return secret } + const createDatalink = (rest: Partial): backend.DatalinkAsset => { + const datalink = object.merge( + { + type: backend.AssetType.datalink, + id: backend.DatalinkId('datalink-' + uniqueString.uniqueString()), + projectState: null, + extension: null, + title: rest.title ?? '', + modifiedAt: dateTime.toRfc3339(new Date()), + description: rest.description ?? '', + labels: [], + parentId: defaultDirectoryId, + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], + parentsPath: '', + virtualParentsPath: '', + }, + rest, + ) + + Object.defineProperty(datalink, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(datalink, 'parentsPath', { + get: () => getParentPath(datalink.parentId), + }) + + Object.defineProperty(datalink, 'virtualParentsPath', { + get: () => getVirtualParentPath(datalink.parentId, datalink.title), + }) + + return datalink + } + const createLabel = (value: string, color: backend.LChColor): backend.Label => ({ id: backend.TagId('tag-' + uniqueString.uniqueString()), value: backend.LabelName(value), @@ -539,6 +592,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return addAsset(createSecret(rest)) } + const addDatalink = (rest: Partial = {}) => { + return addAsset(createDatalink(rest)) + } + const addLabel = (value: string, color: backend.LChColor) => { const label = createLabel(value, color) labels.push(label) @@ -1109,6 +1166,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { + const force = new URL(request.url()).searchParams.get('force') === 'true' const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] if (!maybeId) return @@ -1117,9 +1175,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // `DirectoryId` to make TypeScript happy. const assetId = decodeURIComponent(maybeId) as backend.DirectoryId - called('deleteAsset', { assetId }) + called('deleteAsset', { assetId, force }) - deleteAsset(assetId) + if (force) { + forceDeleteAsset(assetId) + } else { + deleteAsset(assetId) + } await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) @@ -1365,6 +1427,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { defaultUser, defaultUserId, rootDirectoryId: defaultDirectoryId, + get assetCount() { + return assetMap.size + }, goOffline: () => { isOnline = false }, @@ -1395,10 +1460,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { createProject, createFile, createSecret, + createDatalink, addDirectory, addProject, addFile, addSecret, + addDatalink, createLabel, addLabel, setLabels, diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index f125a3325196..a7fa28156008 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -12,7 +12,7 @@ export interface ContextMenuActions, Context> { readonly snapshot: () => T readonly moveNonFolderToTrash: () => T readonly moveFolderToTrash: () => T - readonly moveAllToTrash: () => T + readonly moveAllToTrash: (confirm?: boolean) => T readonly restoreFromTrash: () => T readonly restoreAllFromTrash: () => T readonly share: () => T @@ -77,13 +77,16 @@ export function contextMenuActions, Context>( // Confirm the deletion in the dialog await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() }), - moveAllToTrash: () => - step('Move all to trash (context menu)', (page) => - page + moveAllToTrash: (hasFolder = false) => + step('Move all to trash (context menu)', async (page) => { + await page .getByRole('button', { name: TEXT.moveAllToTrashShortcut }) .getByText(TEXT.moveAllToTrashShortcut) - .click(), - ), + .click() + if (hasFolder) { + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + } + }), restoreFromTrash: () => step('Restore from trash (context menu)', (page) => page diff --git a/app/gui/integration-test/dashboard/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index c2bfd6d18d24..360bc8f69c9d 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -1,6 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ import { expect, test } from '@playwright/test' +import { modModifier } from 'integration-test/dashboard/actions/BaseActions' import { mockAllAndLogin, TEXT } from './actions' test('delete and restore', ({ page }) => @@ -54,3 +55,46 @@ test('delete and restore (keyboard)', ({ page }) => .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(1) })) + +test('clear trash', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory() + api.addDirectory() + api.addProject() + api.addProject() + api.addFile() + api.addSecret() + api.addDatalink() + }, + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(7) + }) + .driveTable.withRows(async (rows, _nonRows, _context, page) => { + const mod = await modModifier(page) + // Parallelizing this using `Promise.all` makes it inconsistent. + const rowEls = await rows.all() + for (const row of rowEls) { + await row.click({ modifiers: [mod] }) + } + }) + .driveTable.rightClickRow(0) + .contextMenu.moveAllToTrash(true) + .driveTable.expectPlaceholderRow() + .goToCategory.trash() + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(7) + }) + .clearTrash() + .driveTable.expectTrashPlaceholderRow() + .goToCategory.cloud() + .expectStartModal() + .withStartModal(async (startModal) => { + await expect(startModal).toBeVisible() + }) + .close() + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(0) + })) diff --git a/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts b/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts index 20ce3cdc97ae..a38ec0f5ff97 100644 --- a/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts +++ b/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts @@ -2,7 +2,7 @@ import { test, type Page } from '@playwright/test' import * as actions from './actions' import { expect } from './customExpect' import { mockCollapsedFunctionInfo } from './expressionUpdates' -import { CONTROL_KEY } from './keyboard' +import { CONTROL_KEY, DELETE_KEY } from './keyboard' import * as locate from './locate' import { edgesFromNode, edgesToNode } from './locate' import { mockSuggestion } from './suggestionUpdates' @@ -200,6 +200,37 @@ test('Input node is not collapsed', async ({ page }) => { await expect(locate.outputNode(page)).toHaveCount(1) }) +test('Collapsed call shows argument placeholders', async ({ page }) => { + await actions.goToGraph(page) + await mockCollapsedFunctionInfo(page, 'final', 'func1', [0]) + await mockSuggestion(page, { + type: 'method', + module: 'local.Mock_Project.Main', + name: 'func1', + arguments: [ + { + name: 'arg1', + reprType: 'Standard.Base.Any.Any', + isSuspended: false, + hasDefault: false, + defaultValue: null as any, + tagValues: null as any, + }, + ], + selfType: 'local.Mock_Project.Main', + returnType: 'Standard.Base.Any.Any', + isStatic: true, + documentation: '', + annotations: [], + }) + const collapsedCallComponent = locate.graphNodeByBinding(page, 'final') + await locate.graphNodeByBinding(page, 'prod').click() + await page.keyboard.press(DELETE_KEY) + await expect(await edgesToNode(page, collapsedCallComponent)).toHaveCount(0) + await expect(locate.selectedNodes(page)).toHaveCount(0) + await expect(collapsedCallComponent.locator('.WidgetArgumentName .name')).toHaveText('arg1') +}) + async function expectInsideMain(page: Page) { await actions.expectNodePositionsInitialized(page, -16) await expect(locate.graphNode(page)).toHaveCount(MAIN_FILE_NODES) diff --git a/app/gui/integration-test/project-view/expressionUpdates.ts b/app/gui/integration-test/project-view/expressionUpdates.ts index 5846ef888f14..733c9896037f 100644 --- a/app/gui/integration-test/project-view/expressionUpdates.ts +++ b/app/gui/integration-test/project-view/expressionUpdates.ts @@ -8,6 +8,7 @@ export async function mockCollapsedFunctionInfo( page: Page, expression: ExpressionLocator, functionName: string, + notAppliedArguments: number[] = [], ) { await mockMethodCallInfo(page, expression, { methodPointer: { @@ -15,7 +16,7 @@ export async function mockCollapsedFunctionInfo( definedOnType: 'local.Mock_Project.Main', name: functionName, }, - notAppliedArguments: [], + notAppliedArguments, }) } diff --git a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx index ba03a2c1b0e8..4efeb3503160 100644 --- a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx @@ -8,14 +8,15 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as modalProvider from '#/providers/ModalProvider' -import * as ariaComponents from '#/components/AriaComponents' import type * as column from '#/components/dashboard/column' import SvgMask from '#/components/SvgMask' import UpsertSecretModal from '#/modals/UpsertSecretModal' -import type * as backendModule from '#/services/Backend' +import * as backendModule from '#/services/Backend' +import EditableSpan from '#/components/EditableSpan' +import { useText } from '#/providers/TextProvider' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' @@ -37,12 +38,18 @@ export interface SecretNameColumnProps extends column.AssetColumnProps { */ export default function SecretNameColumn(props: SecretNameColumnProps) { const { item, selected, state, rowState, setRowState, isEditable, depth } = props - const { backend } = state + const { backend, nodeMap } = state const toastAndLog = toastAndLogHooks.useToastAndLog() + const { getText } = useText() const { setModal } = modalProvider.useSetModal() const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret')) + const doRename = async (newTitle: string) => { + await updateSecretMutation.mutateAsync([item.id, { title: newTitle, value: null }, item.title]) + setIsEditing(false) + } + const setIsEditing = (isEditingName: boolean) => { if (isEditable) { setRowState(object.merger({ isEditingName })) @@ -69,9 +76,9 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { { + doCreate={async (title, value) => { try { - await updateSecretMutation.mutateAsync([item.id, { value }, item.title]) + await updateSecretMutation.mutateAsync([item.id, { title, value }, item.title]) } catch (error) { toastAndLog(null, error) } @@ -82,14 +89,28 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { }} > - {/* Secrets cannot be renamed. */} - { + setIsEditing(false) + }} + schema={(z) => + z.refine( + (value) => + backendModule.isNewTitleUnique( + item, + value, + nodeMap.current.get(item.parentId)?.children?.map((child) => child.item), + ), + { message: getText('nameShouldBeUnique') }, + ) + } > {item.title} - + ) } diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx index 440ac70788e3..753fb7bbb945 100644 --- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx @@ -209,7 +209,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { setModal( { const ids = new Set([asset.id]) dispatchAssetEvent({ type: AssetEventType.deleteForever, ids }) diff --git a/app/gui/src/dashboard/layouts/AssetProperties.tsx b/app/gui/src/dashboard/layouts/AssetProperties.tsx index 1ebcf95ea778..32d6266609d3 100644 --- a/app/gui/src/dashboard/layouts/AssetProperties.tsx +++ b/app/gui/src/dashboard/layouts/AssetProperties.tsx @@ -356,8 +356,8 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) { canCancel={false} id={item.id} name={item.title} - doCreate={async (name, value) => { - await updateSecretMutation.mutateAsync([item.id, { value }, name]) + doCreate={async (title, value) => { + await updateSecretMutation.mutateAsync([item.id, { title, value }, title]) }} /> diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 2b1e2fc59144..a8829822c218 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -948,9 +948,13 @@ function AssetsTable(props: AssetsTableProps) { { + doCreate={async (title, value) => { try { - await updateSecretMutation.mutateAsync([id, { value }, item.item.title]) + await updateSecretMutation.mutateAsync([ + id, + { title, value }, + item.item.title, + ]) } catch (error) { toastAndLog(null, error) } diff --git a/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx index 7e10c2712f08..ffd00ed4be27 100644 --- a/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx @@ -155,7 +155,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp if (category.type === 'trash') { return ( - selectedKeys.size !== 0 && ( + selectedKeys.size > 1 && (