diff --git a/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx b/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx index cf97ef31..ae85eaff 100644 --- a/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx +++ b/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx @@ -7,12 +7,10 @@ import { ControlPlaneCard } from '../ControlPlaneCard/ControlPlaneCard.tsx'; import { ListWorkspacesType, isWorkspaceReady } from '../../../lib/api/types/crate/listWorkspaces.ts'; import { useMemo, useState } from 'react'; import { MembersAvatarView } from './MembersAvatarView.tsx'; -import { DeleteWorkspaceResource, DeleteWorkspaceType } from '../../../lib/api/types/crate/deleteWorkspace.ts'; -import { useApiResourceMutation, useApiResource } from '../../../lib/api/useApiResource.ts'; +import { useApiResource } from '../../../lib/api/useApiResource.ts'; import { DISPLAY_NAME_ANNOTATION } from '../../../lib/api/types/shared/keyNames.ts'; import { DeleteConfirmationDialog } from '../../Dialogs/DeleteConfirmationDialog.tsx'; import { KubectlDeleteWorkspace } from '../../Dialogs/KubectlCommandInfo/Controllers/KubectlDeleteWorkspace.tsx'; -import { useToast } from '../../../context/ToastContext.tsx'; import { ListControlPlanes } from '../../../lib/api/types/crate/controlPlanes.ts'; import IllustratedError from '../../Shared/IllustratedError.tsx'; import { APIError } from '../../../lib/api/error.ts'; @@ -24,13 +22,19 @@ import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/Illustr import styles from './WorkspacesList.module.css'; import { ControlPlanesListMenu } from '../ControlPlanesListMenu.tsx'; import { CreateManagedControlPlaneWizardContainer } from '../../Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx'; +import { useDeleteWorkspace as _useDeleteWorkspace } from '../../../hooks/useDeleteWorkspace.ts'; interface Props { projectName: string; workspace: ListWorkspacesType; + useDeleteWorkspace?: typeof _useDeleteWorkspace; } -export function ControlPlaneListWorkspaceGridTile({ projectName, workspace }: Props) { +export function ControlPlaneListWorkspaceGridTile({ + projectName, + workspace, + useDeleteWorkspace = _useDeleteWorkspace, +}: Props) { const [isCreateManagedControlPlaneWizardOpen, setIsCreateManagedControlPlaneWizardOpen] = useState(false); const [initialTemplateName, setInitialTemplateName] = useState(undefined); const workspaceName = workspace.metadata.name; @@ -40,13 +44,10 @@ export function ControlPlaneListWorkspaceGridTile({ projectName, workspace }: Pr const { t } = useTranslation(); - const toast = useToast(); const [dialogDeleteWsIsOpen, setDialogDeleteWsIsOpen] = useState(false); const { data: controlplanes, error: cpsError } = useApiResource(ListControlPlanes(projectName, workspaceName)); - const { trigger } = useApiResourceMutation( - DeleteWorkspaceResource(projectNamespace, workspaceName), - ); + const { deleteWorkspace } = useDeleteWorkspace(projectName, projectNamespace, workspaceName); const { mcpCreationGuide } = useLink(); const errorView = createErrorView(cpsError); @@ -181,10 +182,7 @@ export function ControlPlaneListWorkspaceGridTile({ projectName, workspace }: Pr kubectl={} isOpen={dialogDeleteWsIsOpen} setIsOpen={setDialogDeleteWsIsOpen} - onDeletionConfirmed={async () => { - await trigger(); - toast.show(t('ControlPlaneListWorkspaceGridTile.deleteConfirmationDialog')); - }} + onDeletionConfirmed={deleteWorkspace} /> {isCreateManagedControlPlaneWizardOpen ? ( { + let createWorkspacePayload: CreateWorkspaceParams | null = null; + + const fakeUseCreateWorkspace: typeof useCreateWorkspace = () => ({ + createWorkspace: async (data: CreateWorkspaceParams): Promise => { + createWorkspacePayload = data; + }, + isLoading: false, + }); + + const fakeUseAuthOnboarding = (() => ({ + user: { + email: 'name@domain.com', + }, + })) as typeof useAuthOnboarding; + + beforeEach(() => { + createWorkspacePayload = null; + }); + + it('creates a workspace with valid data', () => { + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + const expectedPayload = { + name: 'test-workspace', + displayName: 'Test Workspace Display Name', + chargingTarget: '12345678-1234-1234-1234-123456789abc', + chargingTargetType: 'btp', + members: [ + { + name: 'name@domain.com', + roles: ['admin'], + kind: 'User', + }, + ], + }; + + // Fill in the form (using Shadow DOM selectors) + cy.get('#name').find('input[id*="inner"]').type('test-workspace'); + cy.get('#displayName').find('input[id*="inner"]').type('Test Workspace Display Name'); + + // Select charging target type + cy.get('#chargingTargetType').click(); + cy.contains('BTP').click(); + + // Fill charging target + cy.get('#chargingTarget').find('input[id*="inner"]').type('12345678-1234-1234-1234-123456789abc'); + + // Submit the form + cy.get('ui5-button').contains('Create').click(); + + // Verify the hook was called with correct data + cy.then(() => cy.wrap(createWorkspacePayload).deepEqualJson(expectedPayload)); + + // Dialog should close on success + cy.wrap(setIsOpen).should('have.been.calledWith', false); + }); + + it('validates required fields', () => { + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + // Try to submit without filling required fields + cy.get('ui5-button').contains('Create').click(); + + // Should show validation errors - check for value-state="Negative" attribute + cy.get('#name').should('have.attr', 'value-state', 'Negative'); + + // Or check if error message exists in DOM (even if hidden by CSS) + cy.contains('This field is required').should('exist'); + + // Dialog should not close + cy.wrap(setIsOpen).should('not.have.been.called'); + }); + + it('validates charging target format for BTP', () => { + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + cy.get('#name').find('input[id*="inner"]').type('test-workspace'); + cy.get('#chargingTargetType').click(); + cy.contains('BTP').click(); + + // Invalid format + cy.get('#chargingTarget').find('input[id*="inner"]').type('invalid-format'); + cy.get('ui5-button').contains('Create').click(); + + // Should show validation error - check for value-state="Negative" attribute + cy.get('#chargingTarget').should('have.attr', 'value-state', 'Negative'); + + // Dialog should not close + cy.wrap(setIsOpen).should('not.have.been.called'); + }); + + it('should not close dialog when creation fails', () => { + const failingUseCreateWorkspace: typeof useCreateWorkspace = () => ({ + createWorkspace: async (): Promise => { + throw new Error('Creation failed'); // Simulate failure by throwing error + }, + isLoading: false, + }); + + const setIsOpen = cy.stub(); + + cy.mount( + , + ); + + // Fill in the form + cy.get('#name').find('input[id*="inner"]').type('test-workspace'); + cy.get('#chargingTargetType').click(); + cy.contains('BTP').click(); + cy.get('#chargingTarget').find('input[id*="inner"]').type('12345678-1234-1234-1234-123456789abc'); + + // Submit the form + cy.get('ui5-button').contains('Create').click(); + + // Dialog should NOT close on failure + cy.wrap(setIsOpen).should('not.have.been.called'); + + // Dialog should still be visible + cy.contains('Create').should('be.visible'); + }); +}); diff --git a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx index bca3b319..96dcf7a9 100644 --- a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx +++ b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx @@ -1,23 +1,16 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useApiResourceMutation, useRevalidateApiResource } from '../../lib/api/useApiResource'; -import { ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; -import { APIError } from '../../lib/api/error'; import { CreateProjectWorkspaceDialog, OnCreatePayload } from './CreateProjectWorkspaceDialog.tsx'; -import { - CreateWorkspace, - CreateWorkspaceResource, - CreateWorkspaceType, -} from '../../lib/api/types/crate/createWorkspace'; import { projectnameToNamespace } from '../../utils'; -import { ListWorkspaces } from '../../lib/api/types/crate/listWorkspaces'; -import { useToast } from '../../context/ToastContext.tsx'; -import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; +import { useAuthOnboarding as _useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; import { Member, MemberRoles } from '../../lib/api/types/shared/members.ts'; import { useTranslation } from 'react-i18next'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { createProjectWorkspaceSchema } from '../../lib/api/validations/schemas.ts'; import { ComponentsListItem } from '../../lib/api/types/crate/createManagedControlPlane.ts'; +import { useCreateWorkspace as _useCreateWorkspace } from '../../hooks/useCreateWorkspace.ts'; +import { APIError } from '../../lib/api/error.ts'; +import { ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; export type CreateDialogProps = { name: string; @@ -32,10 +25,14 @@ export function CreateWorkspaceDialogContainer({ isOpen, setIsOpen, project = '', + useCreateWorkspace = _useCreateWorkspace, + useAuthOnboarding = _useAuthOnboarding, }: { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; project?: string; + useCreateWorkspace?: typeof _useCreateWorkspace; + useAuthOnboarding?: typeof _useAuthOnboarding; }) { const { t } = useTranslation(); const validationSchemaProjectWorkspace = useMemo(() => createProjectWorkspaceSchema(t), [t]); @@ -59,6 +56,11 @@ export function CreateWorkspaceDialogContainer({ const { user } = useAuthOnboarding(); const username = user?.email; + const namespace = projectnameToNamespace(project); + + const { createWorkspace } = useCreateWorkspace(project, namespace); + const errorDialogRef = useRef(null); + const clearForm = useCallback(() => { resetField('name'); resetField('chargingTarget'); @@ -74,30 +76,23 @@ export function CreateWorkspaceDialogContainer({ clearForm(); } }, [resetField, setValue, username, isOpen, clearForm]); - const namespace = projectnameToNamespace(project); - const toast = useToast(); - - const { trigger } = useApiResourceMutation(CreateWorkspaceResource(namespace)); - const revalidate = useRevalidateApiResource(ListWorkspaces(project)); - const errorDialogRef = useRef(null); const handleWorkspaceCreate = async ({ name, displayName, chargingTarget, + chargingTargetType, members, }: OnCreatePayload): Promise => { try { - await trigger( - CreateWorkspace(name, namespace, { - displayName: displayName, - chargingTarget: chargingTarget, - members: members, - }), - ); - await revalidate(); + await createWorkspace({ + name, + displayName, + chargingTarget, + chargingTargetType, + members, + }); setIsOpen(false); - toast.show(t('CreateWorkspaceDialog.toastMessage')); return true; } catch (e) { console.error(e); diff --git a/src/hooks/useCreateWorkspace.spec.ts b/src/hooks/useCreateWorkspace.spec.ts new file mode 100644 index 00000000..8bf83e66 --- /dev/null +++ b/src/hooks/useCreateWorkspace.spec.ts @@ -0,0 +1,109 @@ +import { act, renderHook } from '@testing-library/react'; +import { useCreateWorkspace } from './useCreateWorkspace'; +import { describe, it, expect, vi, afterEach, Mock, beforeEach } from 'vitest'; +import { assertNonNullish, assertString } from '../utils/test/vitest-utils'; +import { MemberRoles } from '../lib/api/types/shared/members'; + +// Mock toast and translation +vi.mock('../context/ToastContext', () => ({ + useToast: () => ({ + show: vi.fn(), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('useCreateWorkspace', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should perform a valid create workspace request', async () => { + // ARRANGE + const mockWorkspaceData = { + name: 'test-workspace', + displayName: 'Test Workspace', + chargingTarget: '12345678-1234-1234-1234-123456789abc', + members: [ + { + name: 'user@domain.com', + roles: [MemberRoles.admin], + kind: 'User' as const, + }, + ], + }; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({}), + } as unknown as Response); + + // ACT + const renderHookResult = renderHook(() => useCreateWorkspace('test-project', 'test-project--ns')); + const { createWorkspace } = renderHookResult.result.current; + + await act(async () => { + await createWorkspace(mockWorkspaceData); + }); + + // ASSERT + expect(fetchMock).toHaveBeenCalledTimes(1); + + const call = fetchMock.mock.calls[0]; + const [url, init] = call; + assertNonNullish(init); + const { method, headers, body } = init; + + expect(url).toContain('/api/onboarding/apis/core.openmcp.cloud/v1alpha1/namespaces/test-project--ns/workspaces'); + expect(method).toBe('POST'); + expect(headers).toEqual( + expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-use-crate': 'true', + }), + ); + + assertString(body); + const parsedBody = JSON.parse(body); + expect(parsedBody.metadata.name).toBe('test-workspace'); + expect(parsedBody.metadata.annotations?.['openmcp.cloud/display-name']).toBe('Test Workspace'); + expect(parsedBody.metadata.labels?.['openmcp.cloud.sap/charging-target']).toBe( + '12345678-1234-1234-1234-123456789abc', + ); + expect(parsedBody.spec.members).toHaveLength(1); + expect(parsedBody.spec.members[0].name).toBe('user@domain.com'); + }); + + it('should throw error on API failure', async () => { + // ARRANGE + fetchMock.mockRejectedValue(new Error('API Error')); + + const mockWorkspaceData = { + name: 'test-workspace', + displayName: 'Test Workspace', + chargingTarget: '12345678-1234-1234-1234-123456789abc', + members: [], + }; + + // ACT + const renderHookResult = renderHook(() => useCreateWorkspace('test-project', 'test-project--ns')); + const { createWorkspace } = renderHookResult.result.current; + + // ASSERT + await act(async () => { + await expect(createWorkspace(mockWorkspaceData)).rejects.toThrow('API Error'); + }); + }); +}); diff --git a/src/hooks/useCreateWorkspace.ts b/src/hooks/useCreateWorkspace.ts new file mode 100644 index 00000000..64528509 --- /dev/null +++ b/src/hooks/useCreateWorkspace.ts @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; +import { useApiResourceMutation, useRevalidateApiResource } from '../lib/api/useApiResource'; +import { CreateWorkspace, CreateWorkspaceResource, CreateWorkspaceType } from '../lib/api/types/crate/createWorkspace'; +import { ListWorkspaces } from '../lib/api/types/crate/listWorkspaces'; +import { useToast } from '../context/ToastContext'; +import { Member } from '../lib/api/types/shared/members'; +import { useTranslation } from 'react-i18next'; + +export interface CreateWorkspaceParams { + name: string; + displayName?: string; + chargingTarget?: string; + chargingTargetType?: string; + members: Member[]; +} + +export function useCreateWorkspace(projectName: string, namespace: string) { + const { t } = useTranslation(); + const toast = useToast(); + + const { trigger } = useApiResourceMutation(CreateWorkspaceResource(namespace)); + const revalidate = useRevalidateApiResource(ListWorkspaces(projectName)); + + const createWorkspace = useCallback( + async ({ + name, + displayName, + chargingTarget, + chargingTargetType, + members, + }: CreateWorkspaceParams): Promise => { + await trigger( + CreateWorkspace(name, namespace, { + displayName, + chargingTarget, + chargingTargetType, + members, + }), + ); + await revalidate(); + toast.show(t('CreateWorkspaceDialog.toastMessage')); + }, + [trigger, revalidate, toast, t, namespace], + ); + + return { + createWorkspace, + }; +} diff --git a/src/hooks/useDeleteWorkspace.spec.ts b/src/hooks/useDeleteWorkspace.spec.ts new file mode 100644 index 00000000..0b406ef7 --- /dev/null +++ b/src/hooks/useDeleteWorkspace.spec.ts @@ -0,0 +1,95 @@ +import { act, renderHook } from '@testing-library/react'; +import { useDeleteWorkspace } from './useDeleteWorkspace'; +import { describe, it, expect, vi, afterEach, Mock, beforeEach } from 'vitest'; +import { assertNonNullish } from '../utils/test/vitest-utils'; + +// Mock toast and translation +vi.mock('../context/ToastContext', () => ({ + useToast: () => ({ + show: vi.fn(), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('useDeleteWorkspace', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should perform a valid delete workspace request', async () => { + // ARRANGE + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({}), + } as unknown as Response); + + // ACT + const renderHookResult = renderHook(() => useDeleteWorkspace('test-project', 'test-project--ns', 'test-workspace')); + const { deleteWorkspace } = renderHookResult.result.current; + + await act(async () => { + await deleteWorkspace(); + }); + + // ASSERT + expect(fetchMock).toHaveBeenCalledTimes(1); + + const call = fetchMock.mock.calls[0]; + const [url, init] = call; + assertNonNullish(init); + const { method, headers } = init; + + expect(url).toContain( + '/api/onboarding/apis/core.openmcp.cloud/v1alpha1/namespaces/test-project--ns/workspaces/test-workspace', + ); + expect(method).toBe('DELETE'); + expect(headers).toEqual( + expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-use-crate': 'true', + }), + ); + }); + + it('should throw error on API failure', async () => { + // ARRANGE + fetchMock.mockRejectedValue(new Error('API Error')); + + // ACT + const renderHookResult = renderHook(() => useDeleteWorkspace('test-project', 'test-project--ns', 'test-workspace')); + const { deleteWorkspace } = renderHookResult.result.current; + + // ASSERT + await act(async () => { + await expect(deleteWorkspace()).rejects.toThrow('API Error'); + }); + }); + + it('should throw error on network failure', async () => { + // ARRANGE + fetchMock.mockRejectedValue(new TypeError('Network error')); + + // ACT + const renderHookResult = renderHook(() => useDeleteWorkspace('test-project', 'test-project--ns', 'test-workspace')); + const { deleteWorkspace } = renderHookResult.result.current; + + // ASSERT + await act(async () => { + await expect(deleteWorkspace()).rejects.toThrow('Network error'); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useDeleteWorkspace.ts b/src/hooks/useDeleteWorkspace.ts new file mode 100644 index 00000000..8e4f417a --- /dev/null +++ b/src/hooks/useDeleteWorkspace.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { useApiResourceMutation, useRevalidateApiResource } from '../lib/api/useApiResource'; +import { DeleteWorkspaceResource, DeleteWorkspaceType } from '../lib/api/types/crate/deleteWorkspace'; +import { ListWorkspaces } from '../lib/api/types/crate/listWorkspaces'; +import { useToast } from '../context/ToastContext'; +import { useTranslation } from 'react-i18next'; + +export function useDeleteWorkspace(projectName: string, projectNamespace: string, workspaceName: string) { + const { t } = useTranslation(); + const toast = useToast(); + + const { trigger, isMutating } = useApiResourceMutation( + DeleteWorkspaceResource(projectNamespace, workspaceName), + ); + const revalidate = useRevalidateApiResource(ListWorkspaces(projectName)); + + const deleteWorkspace = useCallback(async (): Promise => { + await trigger(); + await revalidate(); + toast.show(t('ControlPlaneListWorkspaceGridTile.deleteConfirmationDialog')); + }, [trigger, revalidate, toast, t]); + + return { + deleteWorkspace, + isDeleting: isMutating, + }; +}