From 403f884e70e227bf67cfef1679981877f601274c Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:10:33 +0300 Subject: [PATCH 01/10] feat(workspace-details): add changeWorkspaceEditor action creator Creates new DevWorkspaceTemplate, patches workspace annotations and spec.contributions, then deletes the old template. Reuses createDevWorkspaceTemplate and patchWorkspace from existing code. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../backend-client/devWorkspaceTemplateApi.ts | 11 ++ .../__tests__/changeWorkspaceEditor.spec.ts | 153 ++++++++++++++++++ .../actionCreators/changeWorkspaceEditor.ts | 143 ++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts create mode 100644 packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts diff --git a/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts b/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts index dede5c27ad..c368629966 100644 --- a/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts +++ b/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts @@ -76,3 +76,14 @@ export async function patchTemplate( ); } } + +export async function deleteTemplate(namespace: string, name: string): Promise { + const url = `${dashboardBackendPrefix}/namespace/${namespace}/devworkspacetemplates/${name}`; + try { + await AxiosWrapper.createToRetryMissedBearerTokenError().delete(url); + } catch (e) { + throw new Error( + `Failed to delete devWorkspaceTemplate '${name}'. ${common.helpers.errors.getMessage(e)}`, + ); + } +} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts new file mode 100644 index 0000000000..32ec78bc93 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import devfileApi from '@/services/devfileApi'; +import { constructWorkspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { changeWorkspaceEditor } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; + +jest.mock('@/store/SanityCheck', () => ({ + ...jest.requireActual('@/store/SanityCheck'), + verifyAuthorized: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/services/backend-client/devWorkspaceApi'); +jest.mock('@/services/backend-client/devWorkspaceTemplateApi'); + +const wsAnnotations = { + 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest', + 'che.eclipse.org/devfile-source': + 'url:\n location: https://example.com\nfactory:\n params: >-\n che-editor=che-incubator/che-code/latest&storageType=per-user\n', +}; + +function buildWorkspace() { + const dw = new DevWorkspaceBuilder() + .withMetadata({ + name: 'empty-ido0', + namespace: 'test-ns', + uid: 'test-uid', + annotations: wsAnnotations, + }) + .withContributions([{ name: 'editor', kubernetes: { name: 'che-code-empty-ido0' } }]) + .build(); + return constructWorkspace(dw); +} + +describe('changeWorkspaceEditor', () => { + const intellijDevfile = { + schemaVersion: '2.3.0', + metadata: { + name: 'che-idea-server', + attributes: { publisher: 'che-incubator', version: 'latest' }, + }, + components: [ + { + name: 'editor-injector', + container: { image: 'quay.io/che-incubator/che-idea-dev-server:latest' }, + }, + ], + }; + + let mockCreateDevWorkspaceTemplate: jest.Mock; + let mockPatchWorkspace: jest.Mock; + let mockDeleteTemplate: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockCreateDevWorkspaceTemplate = jest.fn().mockResolvedValue({}); + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + createDevWorkspaceTemplate: mockCreateDevWorkspaceTemplate, + }); + + const DwApi = jest.requireMock('@/services/backend-client/devWorkspaceApi'); + mockPatchWorkspace = DwApi.patchWorkspace = jest + .fn() + .mockResolvedValue({ devWorkspace: { metadata: { name: 'empty-ido0' } } }); + + const DwtApi = jest.requireMock('@/services/backend-client/devWorkspaceTemplateApi'); + mockDeleteTemplate = DwtApi.deleteTemplate = jest.fn().mockResolvedValue({}); + }); + + it('creates a new DevWorkspaceTemplate for the new editor', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockCreateDevWorkspaceTemplate).toHaveBeenCalledTimes(1); + const callArgs = mockCreateDevWorkspaceTemplate.mock.calls[0] as unknown[]; + expect(callArgs[0]).toBe('test-ns'); + expect(callArgs[1]).toMatchObject({ metadata: { name: 'empty-ido0' } }); + expect(callArgs[2]).toMatchObject({ + metadata: { + name: 'che-idea-server-empty-ido0', + annotations: { + 'che.eclipse.org/plugin-registry-url': expect.stringContaining('che-idea-server/latest'), + }, + }, + }); + }); + + it('patches che-editor annotation and spec.contributions on the workspace', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockPatchWorkspace).toHaveBeenCalledWith( + 'test-ns', + 'empty-ido0', + expect.arrayContaining([ + expect.objectContaining({ path: '/metadata/annotations' }), + expect.objectContaining({ + path: '/spec/contributions/0/kubernetes/name', + value: 'che-idea-server-empty-ido0', + }), + ]), + ); + + const patches = mockPatchWorkspace.mock.calls[0][2] as Array<{ + path: string; + value: Record; + }>; + const annotationsPatch = patches.find(p => p.path === '/metadata/annotations'); + expect(annotationsPatch?.value['che.eclipse.org/che-editor']).toBe( + 'che-incubator/che-idea-server/latest', + ); + expect(annotationsPatch?.value['che.eclipse.org/devfile-source']).toContain( + 'che-editor=che-incubator/che-idea-server/latest', + ); + }); + + it('deletes the old template', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockDeleteTemplate).toHaveBeenCalledWith('test-ns', 'che-code-empty-ido0'); + }); + + it('throws when editor is not found in cmEditors', async () => { + const store = new MockStoreBuilder().build(); // no cmEditors + const workspace = buildWorkspace(); + await expect( + store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/unknown-editor/latest')), + ).rejects.toThrow('not found'); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts new file mode 100644 index 0000000000..5db43b4ad0 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { V1alpha2DevWorkspaceTemplateSpec } from '@devfile/api'; +import common, { ApplicationId } from '@eclipse-che/common'; +import cloneDeep from 'lodash/cloneDeep'; + +import * as DwApi from '@/services/backend-client/devWorkspaceApi'; +import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; +import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_CHE_EDITOR } from '@/services/devfileApi/devWorkspace/metadata'; +import { Workspace } from '@/services/workspace-adapter'; +import { + COMPONENT_UPDATE_POLICY, + DEVWORKSPACE_DEVFILE_SOURCE, + REGISTRY_URL, +} from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { AppThunk } from '@/store'; +import { selectApplications } from '@/store/ClusterInfo/selectors'; +import { EDITOR_DEVFILE_API_QUERY } from '@/store/DevfileRegistries/const'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { selectServerConfigState } from '@/store/ServerConfig/selectors'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { getEditorName } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const changeWorkspaceEditor = + (workspace: Workspace, newEditorId: string): AppThunk => + async (dispatch, getState) => { + const dw = workspace.ref; + const namespace = dw.metadata.namespace; + const workspaceName = dw.metadata.name; + const state = getState(); + + try { + await verifyAuthorized(dispatch, getState); + dispatch(devWorkspacesRequestAction()); + + // Resolve editor devfile from store (same source checkForTemplatesUpdate uses at start time) + const editors = state.dwPlugins.cmEditors || []; + const editorDevfile = editors.find( + e => + `${e.metadata.attributes.publisher}/${e.metadata.name}/${e.metadata.attributes.version}` === + newEditorId, + ); + if (!editorDevfile) { + throw new Error(`Editor "${newEditorId}" not found in the plugin registry`); + } + + // Build template spec — same logic as checkForTemplatesUpdate uses internally + const spec: Partial = {}; + for (const key in editorDevfile) { + if (key === 'schemaVersion' || key === 'metadata') { + continue; + } + if (key === 'components') { + const components = cloneDeep(editorDevfile.components ?? []); + components.forEach(c => { + if (c.container && !c.container.sourceMapping) { + c.container.sourceMapping = '/projects'; + } + }); + spec.components = components as V1alpha2DevWorkspaceTemplateSpec['components']; + } else { + (spec as Record)[key] = ( + editorDevfile as unknown as Record + )[key]; + } + } + + // Step 1: create new template (reuse createDevWorkspaceTemplate — adds ownerRef + env vars) + const newEditorName = newEditorId.split('/')[1]; + const newTemplateName = `${newEditorName}-${workspaceName}`; + + const newTemplate: devfileApi.DevWorkspaceTemplate = { + apiVersion: 'workspace.devfile.io/v1alpha2', + kind: 'DevWorkspaceTemplate', + metadata: { + name: newTemplateName, + namespace, + annotations: { + [COMPONENT_UPDATE_POLICY]: 'managed', + [REGISTRY_URL]: `${EDITOR_DEVFILE_API_QUERY}${newEditorId}`, + }, + }, + spec, + }; + + const serverConfig = selectServerConfigState(state).config; + const clusterConsole = selectApplications(state).find( + app => app.id === ApplicationId.CLUSTER_CONSOLE, + ); + await getDevWorkspaceClient().createDevWorkspaceTemplate( + namespace, + dw, + newTemplate, + serverConfig?.pluginRegistryURL, + serverConfig?.pluginRegistryInternalURL, + serverConfig?.pluginRegistry?.openVSXURL, + clusterConsole, + ); + + // Step 2: patch workspace — annotations + spec.contributions + const annotations = { ...(dw.metadata.annotations ?? {}) }; + annotations[DEVWORKSPACE_CHE_EDITOR] = newEditorId; + const devfileSource = annotations[DEVWORKSPACE_DEVFILE_SOURCE] ?? ''; + annotations[DEVWORKSPACE_DEVFILE_SOURCE] = devfileSource.replace( + /che-editor=[^&\n]+/, + `che-editor=${newEditorId}`, + ); + + const { devWorkspace: updatedDw } = await DwApi.patchWorkspace(namespace, workspaceName, [ + { op: 'replace', path: '/metadata/annotations', value: annotations }, + { op: 'replace', path: '/spec/contributions/0/kubernetes/name', value: newTemplateName }, + ]); + dispatch(devWorkspacesUpdateAction(updatedDw)); + + // Step 3: delete old template (ownerRef also GC-s it on workspace delete — explicit for cleanliness) + const oldTemplateName = getEditorName(dw); + if (oldTemplateName && oldTemplateName !== newTemplateName) { + await DwtApi.deleteTemplate(namespace, oldTemplateName); + } + } catch (e) { + const errorMessage = + `Failed to change editor for workspace ${workspaceName}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; From a82a3a49c9190c0b4680d6cd8f71379375f91f97 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:34:08 +0300 Subject: [PATCH 02/10] feat(editor): add grouping and annotation helpers Assisted-by: Claude Haiku 4.5 Signed-off-by: Oleksii Orel --- .../services/helpers/__tests__/editor.spec.ts | 120 ++++++++++++++++++ .../src/services/helpers/editor.ts | 42 ++++++ 2 files changed, 162 insertions(+) create mode 100644 packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts new file mode 100644 index 0000000000..bd291a657e --- /dev/null +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { + getCurrentEditorId, + getCurrentEditorLabel, + groupEditorsByName, +} from '@/services/helpers/editor'; +import { che } from '@/services/models'; +import { constructWorkspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; + +function makePlugin( + publisher: string, + name: string, + version: string, + displayName: string, +): che.Plugin { + return { + id: `${publisher}/${name}/${version}`, + name, + publisher, + displayName, + type: 'Che Editor', + version, + description: `${displayName} description`, + icon: '', + iconMediatype: 'image/svg+xml', + links: { devfile: '' }, + }; +} + +describe('groupEditorsByName', () => { + it('groups plugins with the same publisher/name together', () => { + const plugins = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-code', 'insiders', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), + ]; + const groups = groupEditorsByName(plugins); + expect(groups).toHaveLength(2); + expect(groups[0].key).toBe('che-incubator/che-code'); + expect(groups[0].displayName).toBe('VS Code - Open Source'); + expect(groups[0].versions).toHaveLength(2); + expect(groups[1].key).toBe('che-incubator/che-idea-server'); + expect(groups[1].versions).toHaveLength(1); + }); + + it('returns an empty array for empty input', () => { + expect(groupEditorsByName([])).toEqual([]); + }); + + it('preserves insertion order of groups', () => { + const plugins = [ + makePlugin('che-incubator', 'che-idea-server', 'latest', 'IntelliJ IDEA'), + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code'), + ]; + const groups = groupEditorsByName(plugins); + expect(groups[0].key).toBe('che-incubator/che-idea-server'); + expect(groups[1].key).toBe('che-incubator/che-code'); + }); +}); + +describe('getCurrentEditorId', () => { + it('returns the annotation value when present', () => { + const dw = new DevWorkspaceBuilder() + .withMetadata({ + annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, + }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorId(workspace)).toBe('che-incubator/che-code/latest'); + }); + + it('returns undefined when annotation is absent', () => { + const dw = new DevWorkspaceBuilder().build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorId(workspace)).toBeUndefined(); + }); +}); + +describe('getCurrentEditorLabel', () => { + const editors = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), + ]; + + it('returns "displayName · version" when the editor is found in the list', () => { + const dw = new DevWorkspaceBuilder() + .withMetadata({ + annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, + }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, editors)).toBe('VS Code - Open Source · latest'); + }); + + it('returns the raw id when the editor is not in the list', () => { + const dw = new DevWorkspaceBuilder() + .withMetadata({ annotations: { 'che.eclipse.org/che-editor': 'unknown/editor/next' } }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, editors)).toBe('unknown/editor/next'); + }); + + it('returns "Default" when annotation is absent', () => { + const dw = new DevWorkspaceBuilder().build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, editors)).toBe('Default'); + }); +}); diff --git a/packages/dashboard-frontend/src/services/helpers/editor.ts b/packages/dashboard-frontend/src/services/helpers/editor.ts index 87960fe26e..9490059fe1 100644 --- a/packages/dashboard-frontend/src/services/helpers/editor.ts +++ b/packages/dashboard-frontend/src/services/helpers/editor.ts @@ -13,6 +13,9 @@ import { dump } from 'js-yaml'; import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_CHE_EDITOR } from '@/services/devfileApi/devWorkspace/metadata'; +import { che } from '@/services/models'; +import { Workspace } from '@/services/workspace-adapter'; const sortOrder: Array = [ 'schemaVersion', @@ -58,3 +61,42 @@ export default function stringify(obj: devfileApi.Devfile | devfileApi.DevWorksp } return dump(obj, { lineWidth, sortKeys }); } + +export type EditorGroup = { + key: string; + displayName: string; + icon: string; + iconMediatype: string; + versions: che.Plugin[]; +}; + +export function groupEditorsByName(editors: che.Plugin[]): EditorGroup[] { + const map = new Map(); + for (const editor of editors) { + const key = `${editor.publisher}/${editor.name}`; + if (!map.has(key)) { + map.set(key, { + key, + displayName: editor.displayName || editor.name, + icon: editor.icon || '', + iconMediatype: editor.iconMediatype || '', + versions: [], + }); + } + map.get(key)!.versions.push(editor); + } + return Array.from(map.values()); +} + +export function getCurrentEditorId(workspace: Workspace): string | undefined { + return workspace.ref.metadata?.annotations?.[DEVWORKSPACE_CHE_EDITOR]; +} + +export function getCurrentEditorLabel(workspace: Workspace, editors: che.Plugin[]): string { + const id = getCurrentEditorId(workspace); + if (!id) { + return 'Default'; + } + const found = editors.find(e => e.id === id); + return found ? `${found.displayName || found.name} · ${found.version}` : id; +} From 7f4bc01456516e6dafaeec30fed1893f2acd08ea Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:44:55 +0300 Subject: [PATCH 03/10] feat(workspace-details): add EditorSelectorModal Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../Editor/SelectorModal.module.css | 21 ++ .../OverviewTab/Editor/SelectorModal.tsx | 230 ++++++++++++++++++ .../Editor/__mocks__/SelectorModal.tsx | 33 +++ .../Editor/__tests__/SelectorModal.spec.tsx | 158 ++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css new file mode 100644 index 0000000000..6bb96d0f36 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +.radioLabel { + display: flex; + gap: 8px; + align-items: center; +} + +.versionLabel { + cursor: default; +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx new file mode 100644 index 0000000000..a5dfd555cc --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { + Button, + Content, + ContentVariants, + Dropdown, + DropdownItem, + DropdownList, + Label, + MenuToggle, + MenuToggleElement, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Radio, +} from '@patternfly/react-core'; +import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; +import React from 'react'; + +import styles from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css'; +import { EditorGroup, groupEditorsByName } from '@/services/helpers/editor'; +import { che } from '@/services/models'; + +export type Props = { + isOpen: boolean; + currentEditorId: string | undefined; + editors: che.Plugin[]; + onConfirm: (editorId: string) => void; + onClose: () => void; +}; + +type State = { + selectedGroupKey: string; + selectedVersion: string; + openDropdownId: string | null; +}; + +function resolveInitialState(currentEditorId: string | undefined, groups: EditorGroup[]): State { + if (!currentEditorId) { + const first = groups[0]; + return { + selectedGroupKey: first?.key ?? '', + selectedVersion: first?.versions[0]?.version ?? '', + openDropdownId: null, + }; + } + // currentEditorId is "publisher/name/version" + const parts = currentEditorId.split('/'); + const version = parts[parts.length - 1]; + const key = parts.slice(0, -1).join('/'); + return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null }; +} + +export class EditorSelectorModal extends React.PureComponent { + constructor(props: Props) { + super(props); + const groups = groupEditorsByName(props.editors); + this.state = resolveInitialState(props.currentEditorId, groups); + } + + public componentDidUpdate(prevProps: Props): void { + if ( + prevProps.currentEditorId !== this.props.currentEditorId || + prevProps.editors !== this.props.editors + ) { + const groups = groupEditorsByName(this.props.editors); + this.setState(resolveInitialState(this.props.currentEditorId, groups)); + } + } + + private get selectedEditorId(): string { + return `${this.state.selectedGroupKey}/${this.state.selectedVersion}`; + } + + private get hasChanged(): boolean { + return this.selectedEditorId !== (this.props.currentEditorId ?? ''); + } + + private handleSelectGroup(group: EditorGroup): void { + this.setState({ + selectedGroupKey: group.key, + selectedVersion: group.versions[0].version, + openDropdownId: null, + }); + } + + private handleVersionSelect( + event: React.MouseEvent | React.KeyboardEvent, + groupKey: string, + version: string, + ): void { + event.stopPropagation(); + event.preventDefault(); + this.setState({ selectedVersion: version, openDropdownId: null, selectedGroupKey: groupKey }); + } + + private buildVersionDropdown(group: EditorGroup): React.ReactElement | null { + if (group.versions.length <= 1) { + return null; + } + const { openDropdownId, selectedVersion, selectedGroupKey } = this.state; + const isOpen = openDropdownId === group.key; + const activeVersion = + selectedGroupKey === group.key ? selectedVersion : group.versions[0].version; + + return ( + ) => ( + { + e.stopPropagation(); + this.setState({ openDropdownId: isOpen ? null : group.key }); + }} + isExpanded={isOpen} + aria-label={`${group.displayName} version options`} + icon={} + /> + )} + isOpen={isOpen} + onOpenChange={open => this.setState({ openDropdownId: open ? group.key : null })} + popperProps={{ appendTo: 'inline', position: 'right' }} + > + + {isOpen + ? group.versions.map(v => ( + this.handleVersionSelect(event, group.key, v.version)} + aria-checked={v.version === activeVersion} + icon={v.version === activeVersion ? : undefined} + > + {v.version} + + )) + : null} + + + ); + } + + public render(): React.ReactNode { + const { isOpen, editors, onConfirm, onClose } = this.props; + const { selectedGroupKey, selectedVersion } = this.state; + + const groups = groupEditorsByName(editors); + + return ( + + + + + {groups.length === 0 ? ( + No editors are available. + ) : ( + <> + Select editor + {groups.map(group => { + const isGroupSelected = selectedGroupKey === group.key; + const activeVersion = isGroupSelected + ? selectedVersion + : group.versions[0].version; + const versionDropdown = this.buildVersionDropdown(group); + + const radioLabel = ( + + {group.displayName} + {isGroupSelected && ( + + )} + {versionDropdown} + + ); + + return ( + + this.handleSelectGroup(group)} + /> + + ); + })} + + )} + + + + + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx new file mode 100644 index 0000000000..7bb67d3bd8 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { Props } from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'; + +export class EditorSelectorModal extends React.PureComponent { + render() { + const { isOpen, onConfirm, onClose, editors } = this.props; + if (!isOpen) { + return null; + } + const firstId = editors[0]?.id ?? ''; + return ( +
+ + +
+ ); + } +} + +export default EditorSelectorModal; diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx new file mode 100644 index 0000000000..4db5843aca --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorSelectorModal } from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { che } from '@/services/models'; + +const { renderComponent } = getComponentRenderer(getComponent); + +function makePlugin( + publisher: string, + name: string, + version: string, + displayName: string, +): che.Plugin { + return { + id: `${publisher}/${name}/${version}`, + name, + publisher, + displayName, + type: 'Che Editor', + version, + icon: '', + iconMediatype: 'image/svg+xml', + links: { devfile: '' }, + }; +} + +const editors: che.Plugin[] = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-code', 'insiders', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), +]; + +const mockOnConfirm = jest.fn(); +const mockOnClose = jest.fn(); + +function getComponent(isOpen: boolean, currentEditorId: string | undefined): React.ReactElement { + return ( + + ); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('EditorSelectorModal', () => { + it('renders nothing when isOpen is false', () => { + renderComponent(false, undefined); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders a dialog when isOpen is true', () => { + renderComponent(true, undefined); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Change Editor')).toBeInTheDocument(); + }); + + it('renders one radio per editor group', () => { + renderComponent(true, undefined); + const radios = screen.getAllByRole('radio'); + // 2 groups: che-code, che-idea-server + expect(radios).toHaveLength(2); + }); + + it('pre-selects the radio that matches currentEditorId', () => { + renderComponent(true, 'che-incubator/che-idea-server/latest'); + const radio = screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i }); + expect(radio).toBeChecked(); + }); + + it('Save button is disabled when selection has not changed', () => { + renderComponent(true, 'che-incubator/che-code/latest'); + expect(screen.getByRole('button', { name: /Save/i })).toBeDisabled(); + }); + + it('Save button becomes enabled after selecting a different editor', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); + expect(screen.getByRole('button', { name: /Save/i })).not.toBeDisabled(); + }); + + it('calls onConfirm with the selected editor id on Save', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); + await userEvent.click(screen.getByRole('button', { name: /Save/i })); + expect(mockOnConfirm).toHaveBeenCalledWith('che-incubator/che-idea-server/latest'); + }); + + it('calls onClose on Cancel', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows a version label for each editor group', () => { + renderComponent(true, undefined); + // VS Code - Open Source has versions: latest, insiders + expect(screen.getByText('latest')).toBeInTheDocument(); + }); + + it('shows version dropdown trigger for editors with multiple versions', () => { + renderComponent(true, undefined); + // VS Code has 2 versions → kebab button present + expect( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ).toBeInTheDocument(); + }); + + it('does NOT show version dropdown for editors with a single version', () => { + renderComponent(true, undefined); + // IntelliJ has 1 version → no kebab + expect( + screen.queryByRole('button', { name: /JetBrains IntelliJ IDEA version options/i }), + ).not.toBeInTheDocument(); + }); + + it('updates selected version when a version is chosen from the dropdown', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + // open version dropdown + await userEvent.click( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ); + await userEvent.click(screen.getByRole('menuitem', { name: 'insiders' })); + // now version label shows insiders + expect(screen.getByText('insiders')).toBeInTheDocument(); + }); + + it('calls onConfirm with the correct version when a non-default version is selected then confirmed', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + // switch to insiders version + await userEvent.click( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ); + await userEvent.click(screen.getByRole('menuitem', { name: 'insiders' })); + await userEvent.click(screen.getByRole('button', { name: /Save/i })); + expect(mockOnConfirm).toHaveBeenCalledWith('che-incubator/che-code/insiders'); + }); +}); From e7bf22c07c53d628187611da453ae5dfdbea49c1 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:58:21 +0300 Subject: [PATCH 04/10] feat(workspace-details): add EditorFormGroup to Overview tab Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../OverviewTab/Editor/__mocks__/index.tsx | 33 +++++ .../Editor/__tests__/index.spec.tsx | 118 ++++++++++++++++++ .../OverviewTab/Editor/index.tsx | 99 +++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx new file mode 100644 index 0000000000..244d337b1e --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { Workspace } from '@/services/workspace-adapter'; + +type MockProps = { + readonly: boolean; + workspace: Workspace; +}; + +export class EditorFormGroup extends React.PureComponent { + render() { + return ( +
+ Mock Editor Form Group + +
+ ); + } +} + +export default EditorFormGroup; diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx new file mode 100644 index 0000000000..291c7301fc --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/Editor'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { che } from '@/services/models'; +import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; + +jest.mock('@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'); + +const { renderComponent } = getComponentRenderer(getComponent); + +function makePlugin( + publisher: string, + name: string, + version: string, + displayName: string, +): che.Plugin { + return { + id: `${publisher}/${name}/${version}`, + name, + publisher, + displayName, + type: 'Che Editor', + version, + icon: '', + iconMediatype: 'image/svg+xml', + links: { devfile: '' }, + }; +} + +const editors = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), +]; + +const mockChangeEditor = jest.fn().mockResolvedValue(undefined); + +function buildWorkspace(editorId?: string): Workspace { + const builder = new DevWorkspaceBuilder(); + if (editorId) { + builder.withMetadata({ annotations: { 'che.eclipse.org/che-editor': editorId } }); + } + return constructWorkspace(builder.build()); +} + +function getComponent(readonly: boolean, workspace: Workspace): React.ReactElement { + return ( + + ); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('EditorFormGroup', () => { + it('shows "Default" when no editor annotation is set', () => { + renderComponent(false, buildWorkspace()); + expect(screen.getByText('Default')).toBeInTheDocument(); + }); + + it('shows the editor display name and version', () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.getByText('VS Code - Open Source · latest')).toBeInTheDocument(); + }); + + it('pencil button is disabled when readonly is true', () => { + renderComponent(true, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.getByRole('button', { name: /Change editor/i })).toBeDisabled(); + }); + + it('pencil button is enabled when readonly is false', () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.getByRole('button', { name: /Change editor/i })).not.toBeDisabled(); + }); + + it('opens the selector modal when pencil button is clicked', async () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.queryByTestId('mock-editor-selector-modal')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); + expect(screen.getByTestId('mock-editor-selector-modal')).toBeInTheDocument(); + }); + + it('calls changeEditor with the selected editor id when confirm is clicked', async () => { + const workspace = buildWorkspace('che-incubator/che-code/latest'); + renderComponent(false, workspace); + await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); + await userEvent.click(screen.getByRole('button', { name: /Confirm Editor/i })); + expect(mockChangeEditor).toHaveBeenCalledTimes(1); + expect(mockChangeEditor).toHaveBeenCalledWith(workspace, 'che-incubator/che-code/latest'); + }); + + it('closes the modal without calling changeEditor when Close Modal is clicked', async () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); + await userEvent.click(screen.getByRole('button', { name: /Close Modal/i })); + expect(mockChangeEditor).not.toHaveBeenCalled(); + expect(screen.queryByTestId('mock-editor-selector-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx new file mode 100644 index 0000000000..a375365e62 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Architecture } from '@eclipse-che/common'; +import { Button, FormGroup } from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { EditorSelectorModal } from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'; +import overviewStyles from '@/pages/WorkspaceDetails/OverviewTab/index.module.css'; +import { getCurrentEditorId, getCurrentEditorLabel } from '@/services/helpers/editor'; +import { che } from '@/services/models'; +import { Workspace } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; +import { selectCurrentArchitecture } from '@/store/ClusterConfig/selectors'; +import { selectEditors } from '@/store/Plugins/chePlugins/selectors'; +import { changeWorkspaceEditor } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor'; + +export type Props = MappedProps & { + readonly: boolean; + workspace: Workspace; +}; + +type State = { + isSelectorOpen: boolean; +}; + +export class EditorFormGroup extends React.PureComponent { + state: State = { isSelectorOpen: false }; + + private get filteredEditors(): che.Plugin[] { + const { editors, currentArchitecture } = this.props; + return editors.filter( + e => !currentArchitecture || !e.arch || e.arch.includes(currentArchitecture as Architecture), + ); + } + + private async handleConfirm(newEditorId: string): Promise { + const { workspace, changeEditor } = this.props; + this.setState({ isSelectorOpen: false }); + await changeEditor(workspace, newEditorId); + } + + public render(): React.ReactNode { + const { readonly, workspace } = this.props; + const { isSelectorOpen } = this.state; + const editors = this.filteredEditors; + const label = getCurrentEditorLabel(workspace, editors); + const currentEditorId = getCurrentEditorId(workspace); + + return ( + + + {label} + + + this.handleConfirm(editorId)} + onClose={() => this.setState({ isSelectorOpen: false })} + /> + + ); + } +} + +const mapStateToProps = (state: RootState) => ({ + editors: selectEditors(state), + currentArchitecture: selectCurrentArchitecture(state) as Architecture | undefined, +}); + +const mapDispatchToProps = { + changeEditor: changeWorkspaceEditor, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +type MappedProps = ConnectedProps; +export default connector(EditorFormGroup); From 19cb3b5cb34ba8874f1d2725e78a3a38db7e69a9 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 02:13:35 +0300 Subject: [PATCH 05/10] feat(workspace-details): wire EditorFormGroup into Overview tab Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../__tests__/__snapshots__/index.spec.tsx.snap | 16 ++++++++++++++++ .../OverviewTab/__tests__/index.spec.tsx | 7 +++++++ .../pages/WorkspaceDetails/OverviewTab/index.tsx | 2 ++ 3 files changed, 25 insertions(+) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap index 23ff786fe5..1bd7ed5861 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap @@ -46,6 +46,14 @@ exports[`OverviewTab With parent screenshot 1`] = ` StorageType: +
+ Mock Editor Form Group + +
Mock Projects Form
@@ -103,6 +111,14 @@ exports[`OverviewTab Without parent screenshot 1`] = ` StorageType: +
+ Mock Editor Form Group + +
Mock Projects Form
diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx index d3225bd859..2f510538ac 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx @@ -21,6 +21,7 @@ import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +jest.mock('@/pages/WorkspaceDetails/OverviewTab/Editor'); jest.mock('@/pages/WorkspaceDetails/OverviewTab/InfrastructureNamespace'); jest.mock('@/pages/WorkspaceDetails/OverviewTab/Projects'); jest.mock('@/pages/WorkspaceDetails/OverviewTab/StorageType'); @@ -185,6 +186,12 @@ describe('OverviewTab', () => { expect(mockOnSave).toHaveBeenCalledTimes(1); }); }); + + it('renders EditorFormGroup', async () => { + const workspace = constructWorkspace(new DevWorkspaceBuilder().build()); + renderComponent(workspace); + expect(screen.getByRole('button', { name: 'Change editor' })).toBeInTheDocument(); + }); }); function getComponent(workspace: Workspace) { diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx index 7d9f432265..f5cf8bf968 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx @@ -15,6 +15,7 @@ import cloneDeep from 'lodash/cloneDeep'; import React from 'react'; import AiToolFormGroup from '@/pages/WorkspaceDetails/OverviewTab/AiTool'; +import EditorFormGroup from '@/pages/WorkspaceDetails/OverviewTab/Editor'; import GitRepoFormGroup from '@/pages/WorkspaceDetails/OverviewTab/GitRepo'; import { InfrastructureNamespaceFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/InfrastructureNamespace'; import { ProjectsFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/Projects'; @@ -119,6 +120,7 @@ export class OverviewTab extends React.Component { parentStorageType={parentStorageType} onSave={storageType => this.handleStorageSave(storageType)} /> + Date: Fri, 4 Sep 2026 16:34:58 +0300 Subject: [PATCH 06/10] fix(backend): register DWT DELETE route unconditionally The DELETE /namespace/:ns/devworkspacetemplates/:name route was wrapped in isLocalRun(), making it unavailable in production deployments. The changeWorkspaceEditor action creator calls deleteTemplate() after switching the editor, so the orphaned old template was never cleaned up on a real cluster. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../__tests__/devworkspaceTemplates.spec.ts | 28 ------------- .../src/routes/api/devworkspaceTemplates.ts | 42 +++++++++---------- 2 files changed, 19 insertions(+), 51 deletions(-) diff --git a/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts index 04953bae34..2ab8831e01 100644 --- a/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts +++ b/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts @@ -92,34 +92,6 @@ describe('DevWorkspaceTemplates Routes', () => { expect(res.json()).toEqual(stubDevWorkspaceTemplate); }); - test('DELETE ${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName', async () => { - const templateName = 'tmpl'; - const res = await app - .inject() - .delete(`${baseApiPath}/namespace/${namespace}/devworkspacetemplates/${templateName}`); - - expect(res.statusCode).toEqual(404); - }); - }); - - describe('LocalRun only', () => { - beforeAll(async () => { - const env = { - OPENSHIFT_CONSOLE_URL: clusterConsoleUrl, - LOCAL_RUN: 'true', - CHE_API_PROXY_UPSTREAM: 'http://127.0.0.1:80', - }; - app = await setup({ env }); - }); - - afterAll(() => { - teardown(app); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - test('DELETE ${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName', async () => { const templateName = 'tmpl'; const res = await app diff --git a/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts b/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts index 55500ec0ca..252294537a 100644 --- a/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts +++ b/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts @@ -19,7 +19,6 @@ import { namespacedTemplateSchema, templateStartedSchema, } from '@/constants/schemas'; -import { isLocalRun } from '@/localRun'; import { restParams } from '@/models'; import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; import { getToken } from '@/routes/api/helpers/getToken'; @@ -89,30 +88,27 @@ export function registerDevWorkspaceTemplates(instance: FastifyInstance) { }, ); - if (isLocalRun()) { - server.delete( - `${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName`, - getSchema({ - tags, - params: namespacedTemplateSchema, - response: { - 204: { - description: 'The DevWorkspaceTemplate successfully deleted', - type: 'null', - }, + server.delete( + `${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName`, + getSchema({ + tags, + params: namespacedTemplateSchema, + response: { + 204: { + description: 'The DevWorkspaceTemplate successfully deleted', + type: 'null', }, - }), - async function (request: FastifyRequest, reply: FastifyReply) { - const { namespace, templateName } = - request.params as restParams.INamespacedTemplateParams; - const token = getToken(request); - const { devWorkspaceTemplateApi: templateApi } = getDevWorkspaceClient(token); + }, + }), + async function (request: FastifyRequest, reply: FastifyReply) { + const { namespace, templateName } = request.params as restParams.INamespacedTemplateParams; + const token = getToken(request); + const { devWorkspaceTemplateApi: templateApi } = getDevWorkspaceClient(token); - await templateApi.delete(namespace, templateName); + await templateApi.delete(namespace, templateName); - reply.code(204).send(); - }, - ); - } + reply.code(204).send(); + }, + ); }); } From 98ada5e3a20d78da3712ec5dc4567dff04bc8407 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 16:35:18 +0300 Subject: [PATCH 07/10] fix(workspace-details): polish Change Editor UI - Overview tab: remove version from editor label; hide pencil button (not just disable) when workspace is not stopped/failed; add tooltip icon (Popover + FormGroupLabelHelp) matching AI Tool row alignment - Change Editor modal: fixed header (title + filter) does not scroll with the list; filter now searches display name, all versions, and all descriptions; filter placeholder shortened to "Filter by"; filter input capped at max-width 30%; version labels shown on all rows, not just the selected one; checkbox style (type=checkbox) instead of radio; versionLabel style matches AI Tool (height, margin-left, no min-width); custom editor shown as informational item when current editor ID is unknown to the registry - After confirming an editor change, show "Workspace has been updated" success alert or a danger alert with the error message, matching the AI Tool behaviour (uses AppAlerts via lazyInject) Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../Editor/SelectorModal.module.css | 30 ++++- .../OverviewTab/Editor/SelectorModal.tsx | 126 ++++++++++++------ .../Editor/__tests__/SelectorModal.spec.tsx | 69 +++++++--- .../Editor/__tests__/index.spec.tsx | 45 +++++-- .../OverviewTab/Editor/index.tsx | 69 +++++++--- .../services/helpers/__tests__/editor.spec.ts | 4 +- .../src/services/helpers/editor.ts | 2 +- 7 files changed, 259 insertions(+), 86 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css index 6bb96d0f36..0ebba50198 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -10,12 +10,36 @@ * Red Hat, Inc. - initial API and implementation */ -.radioLabel { +.editorSelectHeader { display: flex; - gap: 8px; + gap: 1rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.filterInput { + max-width: 30%; +} + +.editorList { + overflow-y: auto; + max-height: 18rem; +} + +.radioLabel { + display: inline-flex; + gap: 4px; align-items: center; } .versionLabel { - cursor: default; + height: 0.8125rem; + margin-left: 0; +} + +.customEditorRow { + display: inline-flex; + gap: 4px; + align-items: center; + color: var(--pf-t--global--text--color--subtle); } diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index a5dfd555cc..794c07b099 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -12,6 +12,7 @@ import { Button, + Checkbox, Content, ContentVariants, Dropdown, @@ -25,7 +26,7 @@ import { ModalFooter, ModalHeader, ModalVariant, - Radio, + TextInput, } from '@patternfly/react-core'; import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; import React from 'react'; @@ -46,6 +47,7 @@ type State = { selectedGroupKey: string; selectedVersion: string; openDropdownId: string | null; + filterText: string; }; function resolveInitialState(currentEditorId: string | undefined, groups: EditorGroup[]): State { @@ -55,13 +57,13 @@ function resolveInitialState(currentEditorId: string | undefined, groups: Editor selectedGroupKey: first?.key ?? '', selectedVersion: first?.versions[0]?.version ?? '', openDropdownId: null, + filterText: '', }; } - // currentEditorId is "publisher/name/version" const parts = currentEditorId.split('/'); const version = parts[parts.length - 1]; const key = parts.slice(0, -1).join('/'); - return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null }; + return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null, filterText: '' }; } export class EditorSelectorModal extends React.PureComponent { @@ -79,6 +81,10 @@ export class EditorSelectorModal extends React.PureComponent { const groups = groupEditorsByName(this.props.editors); this.setState(resolveInitialState(this.props.currentEditorId, groups)); } + // Reset filter when modal closes + if (prevProps.isOpen && !this.props.isOpen) { + this.setState({ filterText: '' }); + } } private get selectedEditorId(): string { @@ -155,11 +161,30 @@ export class EditorSelectorModal extends React.PureComponent { } public render(): React.ReactNode { - const { isOpen, editors, onConfirm, onClose } = this.props; - const { selectedGroupKey, selectedVersion } = this.state; + const { isOpen, currentEditorId, editors, onConfirm, onClose } = this.props; + const { selectedGroupKey, selectedVersion, filterText } = this.state; const groups = groupEditorsByName(editors); + const currentGroupKey = currentEditorId + ? currentEditorId.split('/').slice(0, -1).join('/') + : undefined; + const isCustomEditor = + currentGroupKey !== undefined && !groups.some(g => g.key === currentGroupKey); + + const lowerFilter = filterText.toLowerCase(); + const filteredGroups = filterText + ? groups.filter( + g => + g.displayName.toLowerCase().includes(lowerFilter) || + g.versions.some( + v => + v.version.toLowerCase().includes(lowerFilter) || + (v.description ?? '').toLowerCase().includes(lowerFilter), + ), + ) + : groups; + return ( { > - +
{groups.length === 0 ? ( No editors are available. ) : ( <> - Select editor - {groups.map(group => { - const isGroupSelected = selectedGroupKey === group.key; - const activeVersion = isGroupSelected - ? selectedVersion - : group.versions[0].version; - const versionDropdown = this.buildVersionDropdown(group); - - const radioLabel = ( - - {group.displayName} - {isGroupSelected && ( -
- + + + + } + > + {readonly && {label}} + {!readonly && ( + + {label} + + + )} { makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), ]; - it('returns "displayName · version" when the editor is found in the list', () => { + it('returns displayName when the editor is found in the list', () => { const dw = new DevWorkspaceBuilder() .withMetadata({ annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, }) .build(); const workspace = constructWorkspace(dw); - expect(getCurrentEditorLabel(workspace, editors)).toBe('VS Code - Open Source · latest'); + expect(getCurrentEditorLabel(workspace, editors)).toBe('VS Code - Open Source'); }); it('returns the raw id when the editor is not in the list', () => { diff --git a/packages/dashboard-frontend/src/services/helpers/editor.ts b/packages/dashboard-frontend/src/services/helpers/editor.ts index 9490059fe1..af0b9a5d49 100644 --- a/packages/dashboard-frontend/src/services/helpers/editor.ts +++ b/packages/dashboard-frontend/src/services/helpers/editor.ts @@ -98,5 +98,5 @@ export function getCurrentEditorLabel(workspace: Workspace, editors: che.Plugin[ return 'Default'; } const found = editors.find(e => e.id === id); - return found ? `${found.displayName || found.name} · ${found.version}` : id; + return found ? found.displayName || found.name : id; } From 133683f42f8d97d7ce6a6686978f0b8fdc098054 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 16:43:23 +0300 Subject: [PATCH 08/10] fix(workspace-details): rename filter aria-label to "Filter editors by" Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx | 2 +- .../OverviewTab/Editor/__tests__/SelectorModal.spec.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index 794c07b099..b8fcd61981 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -208,7 +208,7 @@ export class EditorSelectorModal extends React.PureComponent { placeholder="Filter by" value={filterText} onChange={(_event, value) => this.setState({ filterText: value })} - aria-label="Filter editors by name" + aria-label="Filter editors by" className={styles.filterInput} /> diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx index a833bcc935..134ad2b354 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -156,7 +156,7 @@ describe('EditorSelectorModal', () => { it('filters editors by display name', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'JetBrains'); expect(screen.queryByRole('checkbox', { name: /VS Code/i })).not.toBeInTheDocument(); expect(screen.getByRole('checkbox', { name: /JetBrains IntelliJ IDEA/i })).toBeInTheDocument(); @@ -164,7 +164,7 @@ describe('EditorSelectorModal', () => { it('filters editors by version string', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'insiders'); expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); expect(screen.queryByRole('checkbox', { name: /JetBrains/i })).not.toBeInTheDocument(); @@ -172,7 +172,7 @@ describe('EditorSelectorModal', () => { it('filters editors by description text', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'Open Source IDE'); expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); expect(screen.queryByRole('checkbox', { name: /JetBrains/i })).not.toBeInTheDocument(); @@ -180,7 +180,7 @@ describe('EditorSelectorModal', () => { it('shows "No editors match the filter" when filter yields no results', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'NonExistentEditor'); expect(screen.getByText('No editors match the filter.')).toBeInTheDocument(); }); From b0a131fd602c8bcedf61da89bb10ccc3e66e4ba1 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 17:09:15 +0300 Subject: [PATCH 09/10] fix(workspace-details): adjust Overview tab readonly/editable alignment Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../src/pages/WorkspaceDetails/OverviewTab/index.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css index 01d01386f8..26ff4e5c52 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css @@ -12,13 +12,13 @@ .editable { position: relative; - top: 2px; + bottom: 2px; color: var(--pf-t--global--text--color--link--default); } .readonly { display: inline-block; - padding-top: 8px; + padding-top: 5px; } .labelHelp { From 2b372aea732c9c826c7771730b8e4aca9ecb41e5 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 18:08:51 +0300 Subject: [PATCH 10/10] test(workspace-details): improve coverage for Change Editor feature - devWorkspaceTemplateApi: add deleteTemplate success + error tests - changeWorkspaceEditor: add error-path and same-name-skip tests - editor helpers: add displayName/name fallback tests - SelectorModal: add filter-reset-on-close test - EditorFormGroup: add architecture-filter test Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../Editor/__tests__/SelectorModal.spec.tsx | 16 +++++++++ .../Editor/__tests__/index.spec.tsx | 28 +++++++++++++-- .../__tests__/devWorkspaceTemplateApi.spec.ts | 21 +++++++++++ .../services/helpers/__tests__/editor.spec.ts | 27 ++++++++++++++ .../__tests__/changeWorkspaceEditor.spec.ts | 35 +++++++++++++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx index 134ad2b354..8b178df521 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -190,4 +190,20 @@ describe('EditorSelectorModal', () => { expect(screen.getByText('custom-publisher/my-editor/dev')).toBeInTheDocument(); expect(screen.getByText('custom')).toBeInTheDocument(); }); + + it('resets the filter when the modal is closed then reopened', async () => { + const { reRenderComponent } = renderComponent(true, undefined); + // apply a filter + await userEvent.type( + screen.getByRole('searchbox', { name: /Filter editors by/i }), + 'JetBrains', + ); + expect(screen.queryByRole('checkbox', { name: /VS Code/i })).not.toBeInTheDocument(); + + // close + reRenderComponent(false, undefined); + // reopen — filter must be cleared + reRenderComponent(true, undefined); + expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); + }); }); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx index 7de76a51ab..7bef542067 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx @@ -10,6 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ +import { Architecture } from '@eclipse-che/common'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -51,6 +52,11 @@ const editors = [ makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), ]; +const amd64OnlyEditor: che.Plugin = { + ...makePlugin('che-incubator', 'amd64-only', 'latest', 'AMD64 Only Editor'), + arch: ['amd64'], +}; + const mockChangeEditor = jest.fn().mockResolvedValue(undefined); function buildWorkspace(editorId?: string): Workspace { @@ -61,13 +67,13 @@ function buildWorkspace(editorId?: string): Workspace { return constructWorkspace(builder.build()); } -function getComponent(readonly: boolean, workspace: Workspace): React.ReactElement { +function getComponent(readonly: boolean, workspace: Workspace, arch?: string): React.ReactElement { return ( ); @@ -137,6 +143,24 @@ describe('EditorFormGroup', () => { ); }); + it('filters editors by architecture when currentArchitecture is set', () => { + // amd64OnlyEditor should be excluded when architecture is arm64 + const { renderComponent: render } = getComponentRenderer( + (readonly: boolean, workspace: Workspace) => ( + + ), + ); + render(false, buildWorkspace('che-incubator/amd64-only/latest')); + // label falls back to raw id since amd64-only is filtered out + expect(screen.getByText('che-incubator/amd64-only/latest')).toBeInTheDocument(); + }); + it('closes the modal without calling changeEditor when Close Modal is clicked', async () => { renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); diff --git a/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts b/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts index f832fc1405..59bc7aae61 100644 --- a/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts +++ b/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts @@ -15,6 +15,7 @@ import mockAxios from 'axios'; import { createTemplate, + deleteTemplate, getTemplateByName, getTemplates, patchTemplate, @@ -25,6 +26,7 @@ describe('DevWorkspaceTemplate API', () => { const mockGet = mockAxios.get as jest.Mock; const mockPost = mockAxios.post as jest.Mock; const mockPatch = mockAxios.patch as jest.Mock; + const mockDelete = mockAxios.delete as jest.Mock; const namespace = 'test-name'; const devWorkspaceTemplateName = 'che-code'; @@ -143,4 +145,23 @@ describe('DevWorkspaceTemplate API', () => { expect(res).toEqual(devWorkspaceTemplate); }); }); + + describe('delete a DevWorkspaceTemplate', () => { + it('should call the correct URL on success', async () => { + mockDelete.mockResolvedValueOnce(new Promise(resolve => resolve({ data: undefined }))); + await deleteTemplate(namespace, devWorkspaceTemplateName); + + expect(mockDelete).toHaveBeenCalledWith( + '/dashboard/api/namespace/test-name/devworkspacetemplates/che-code', + undefined, + ); + }); + + it('should throw a wrapped error when the request fails', async () => { + mockDelete.mockRejectedValueOnce(new Error('network failure')); + await expect(deleteTemplate(namespace, devWorkspaceTemplateName)).rejects.toThrow( + "Failed to delete devWorkspaceTemplate 'che-code'", + ); + }); + }); }); diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts index ed48dd9b98..369509b346 100644 --- a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts @@ -68,6 +68,19 @@ describe('groupEditorsByName', () => { expect(groups[0].key).toBe('che-incubator/che-idea-server'); expect(groups[1].key).toBe('che-incubator/che-code'); }); + + it('falls back to plugin name when displayName is empty', () => { + const plugin: che.Plugin = { + ...makePlugin('che-incubator', 'che-code', 'latest', ''), + displayName: '', + icon: '', + iconMediatype: '', + }; + const groups = groupEditorsByName([plugin]); + expect(groups[0].displayName).toBe('che-code'); + expect(groups[0].icon).toBe(''); + expect(groups[0].iconMediatype).toBe(''); + }); }); describe('getCurrentEditorId', () => { @@ -117,4 +130,18 @@ describe('getCurrentEditorLabel', () => { const workspace = constructWorkspace(dw); expect(getCurrentEditorLabel(workspace, editors)).toBe('Default'); }); + + it('falls back to plugin name when found editor has no displayName', () => { + const editorWithoutDisplayName: che.Plugin = { + ...makePlugin('che-incubator', 'che-code', 'latest', ''), + displayName: '', + }; + const dw = new DevWorkspaceBuilder() + .withMetadata({ + annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, + }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, [editorWithoutDisplayName])).toBe('che-code'); + }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts index 32ec78bc93..bbfddf23ce 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts @@ -150,4 +150,39 @@ describe('changeWorkspaceEditor', () => { store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/unknown-editor/latest')), ).rejects.toThrow('not found'); }); + + it('rethrows when patchWorkspace fails', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + mockPatchWorkspace.mockRejectedValueOnce(new Error('patch failed')); + + await expect( + store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')), + ).rejects.toThrow('patch failed'); + }); + + it('skips delete when old and new template names are the same', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + // workspace already points to the target editor + const dw = new DevWorkspaceBuilder() + .withMetadata({ + name: 'empty-ido0', + namespace: 'test-ns', + uid: 'test-uid', + annotations: { + ...wsAnnotations, + 'che.eclipse.org/che-editor': 'che-incubator/che-idea-server/latest', + }, + }) + .withContributions([{ name: 'editor', kubernetes: { name: 'che-idea-server-empty-ido0' } }]) + .build(); + const workspace = constructWorkspace(dw); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockDeleteTemplate).not.toHaveBeenCalled(); + }); });