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(); + }, + ); }); } 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..0ebba50198 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -0,0 +1,45 @@ +/* + * 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 + */ + +.editorSelectHeader { + display: flex; + 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 { + 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 new file mode 100644 index 0000000000..b8fcd61981 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -0,0 +1,280 @@ +/* + * 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, + Checkbox, + Content, + ContentVariants, + Dropdown, + DropdownItem, + DropdownList, + Label, + MenuToggle, + MenuToggleElement, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + TextInput, +} 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; + filterText: string; +}; + +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, + filterText: '', + }; + } + const parts = currentEditorId.split('/'); + const version = parts[parts.length - 1]; + const key = parts.slice(0, -1).join('/'); + return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null, filterText: '' }; +} + +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)); + } + // Reset filter when modal closes + if (prevProps.isOpen && !this.props.isOpen) { + this.setState({ filterText: '' }); + } + } + + 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, 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 + + this.setState({ filterText: value })} + aria-label="Filter editors by" + className={styles.filterInput} + /> +
+
+ {isCustomEditor && ( + +
+ {currentEditorId} + +
+
+ )} + {filteredGroups.length === 0 ? ( + No editors match the filter. + ) : ( + filteredGroups.map(group => { + const isGroupSelected = selectedGroupKey === group.key; + const activeVersion = isGroupSelected + ? selectedVersion + : group.versions[0].version; + const versionDropdown = this.buildVersionDropdown(group); + + const radioLabel = ( + + {group.displayName} + + {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/__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__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx new file mode 100644 index 0000000000..8b178df521 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -0,0 +1,209 @@ +/* + * 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'), + description: 'Microsoft Visual Studio Code - Open Source IDE for Eclipse Che', + }, + 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 checkbox per editor group', () => { + renderComponent(true, undefined); + const checkboxes = screen.getAllByRole('checkbox'); + // 2 groups: che-code, che-idea-server + expect(checkboxes).toHaveLength(2); + }); + + it('pre-selects the checkbox that matches currentEditorId', () => { + renderComponent(true, 'che-incubator/che-idea-server/latest'); + const checkbox = screen.getByRole('checkbox', { name: /JetBrains IntelliJ IDEA/i }); + expect(checkbox).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('checkbox', { 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('checkbox', { 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 every editor group', () => { + renderComponent(true, undefined); + // VS Code (2 versions) shows 'latest' (active), IntelliJ (1 version) shows 'latest' + expect(screen.getAllByText('latest')).toHaveLength(2); + }); + + it('shows version dropdown trigger for editors with multiple versions', () => { + renderComponent(true, undefined); + 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); + 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'); + await userEvent.click( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ); + await userEvent.click(screen.getByRole('menuitem', { name: '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'); + 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'); + }); + + it('filters editors by display name', async () => { + renderComponent(true, undefined); + 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(); + }); + + it('filters editors by version string', async () => { + renderComponent(true, undefined); + 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(); + }); + + it('filters editors by description text', async () => { + renderComponent(true, undefined); + 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(); + }); + + 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/i }); + await userEvent.type(filter, 'NonExistentEditor'); + expect(screen.getByText('No editors match the filter.')).toBeInTheDocument(); + }); + + it('shows a custom label when currentEditorId does not match any known editor', () => { + renderComponent(true, 'custom-publisher/my-editor/dev'); + 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 new file mode 100644 index 0000000000..7bef542067 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx @@ -0,0 +1,171 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { container } from '@/inversify.config'; +import { EditorFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/Editor'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { AppAlerts } from '@/services/alerts/appAlerts'; +import { AlertItem } from '@/services/helpers/types'; +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 mockShowAlert = jest.fn(); +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 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 { + 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, arch?: string): React.ReactElement { + return ( + + ); +} + +beforeEach(() => { + class MockAppAlerts extends AppAlerts { + showAlert(alert: AlertItem): void { + mockShowAlert(alert); + } + } + container.snapshot(); + container.rebind(AppAlerts).to(MockAppAlerts).inSingletonScope(); +}); + +afterEach(() => { + jest.clearAllMocks(); + container.restore(); +}); + +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 without version', () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.getByText('VS Code - Open Source')).toBeInTheDocument(); + }); + + it('pencil button is not rendered when readonly is true', () => { + renderComponent(true, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.queryByRole('button', { name: /Change editor/i })).not.toBeInTheDocument(); + }); + + it('pencil button is rendered and 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 and shows success alert on confirm', 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).toHaveBeenCalledWith(workspace, 'che-incubator/che-code/latest'); + expect(mockShowAlert).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'success', title: 'Workspace has been updated' }), + ); + }); + + it('shows danger alert when changeEditor throws', async () => { + mockChangeEditor.mockRejectedValueOnce(new Error('patch failed')); + 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: /Confirm Editor/i })); + expect(mockShowAlert).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'danger', title: 'patch failed' }), + ); + }); + + 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 })); + 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..6f7cda4653 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx @@ -0,0 +1,134 @@ +/* + * 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 common, { Architecture } from '@eclipse-che/common'; +import { + AlertVariant, + Button, + FormGroup, + FormGroupLabelHelp, + Popover, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { lazyInject } from '@/inversify.config'; +import { EditorSelectorModal } from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'; +import overviewStyles from '@/pages/WorkspaceDetails/OverviewTab/index.module.css'; +import { AppAlerts } from '@/services/alerts/appAlerts'; +import { getCurrentEditorId, getCurrentEditorLabel } from '@/services/helpers/editor'; +import getRandomString from '@/services/helpers/random'; +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 { + @lazyInject(AppAlerts) + private appAlerts: AppAlerts; + + 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 }); + try { + await changeEditor(workspace, newEditorId); + this.appAlerts.showAlert({ + key: 'editor-change-' + getRandomString(4), + title: 'Workspace has been updated', + variant: AlertVariant.success, + }); + } catch (e) { + this.appAlerts.showAlert({ + key: 'editor-change-error-' + getRandomString(4), + title: common.helpers.errors.getMessage(e), + variant: AlertVariant.danger, + }); + } + } + + 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 ( + + + + } + > + {readonly && {label}} + {!readonly && ( + + {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); 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.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 { 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)} /> + { 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/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/services/helpers/__tests__/editor.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts new file mode 100644 index 0000000000..369509b346 --- /dev/null +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts @@ -0,0 +1,147 @@ +/* + * 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'); + }); + + 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', () => { + 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 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'); + }); + + 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'); + }); + + 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/services/helpers/editor.ts b/packages/dashboard-frontend/src/services/helpers/editor.ts index 87960fe26e..af0b9a5d49 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 : id; +} 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..bbfddf23ce --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts @@ -0,0 +1,188 @@ +/* + * 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'); + }); + + 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(); + }); +}); 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; + } + };