diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index f8ac1767..f0a5aca1 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -150,6 +150,7 @@ "Delete security group": "Delete security group", "Delete security group?": "Delete security group?", "Deleting": "Deleting", + "Deprecate": "Deprecate", "deprecated": "deprecated", "Deprecated": "Deprecated", "Description": "Description", @@ -201,9 +202,11 @@ "Failed to delete cluster": "Failed to delete cluster", "Failed to delete compute instance": "Failed to delete compute instance", "Failed to delete Identity provider": "Failed to delete Identity provider", + "Failed to delete instance type": "Failed to delete instance type", "Failed to delete storage backend": "Failed to delete storage backend", "Failed to delete storage tier": "Failed to delete storage tier", "Failed to delete tenant": "Failed to delete tenant", + "Failed to deprecate instance type": "Failed to deprecate instance type", "Failed to disable Identity provider": "Failed to disable Identity provider", "Failed to download kubeconfig": "Failed to download kubeconfig", "Failed to edit resource": "Failed to edit resource", @@ -218,6 +221,8 @@ "Failed to load graphical console viewer": "Failed to load graphical console viewer", "Failed to load security groups": "Failed to load security groups", "Failed to load subnets": "Failed to load subnets", + "Failed to mark instance type as obsolete": "Failed to mark instance type as obsolete", + "Failed to reactivate instance type": "Failed to reactivate instance type", "Failed to restart virtual machine": "Failed to restart virtual machine", "Failed to retrieve break-glass credentials.": "Failed to retrieve break-glass credentials.", "Failed to start virtual machine": "Failed to start virtual machine", @@ -351,6 +356,7 @@ "Pull secret": "Pull secret", "Pull secret is required": "Pull secret is required", "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).", + "Reactivate": "Reactivate", "Ready": "Ready", "Registered": "Registered", "Release image": "Release image", @@ -415,6 +421,7 @@ "This permanently deletes the cluster and all its resources. This action cannot be undone.": "This permanently deletes the cluster and all its resources. This action cannot be undone.", "This permanently deletes the compute instance. This action cannot be undone.": "This permanently deletes the compute instance. This action cannot be undone.", "This permanently deletes the Identity provider and all its resources. This action cannot be undone.": "This permanently deletes the Identity provider and all its resources. This action cannot be undone.", + "This permanently deletes the instance type. This action cannot be undone.": "This permanently deletes the instance type. This action cannot be undone.", "This permanently deletes the storage backend. This action cannot be undone.": "This permanently deletes the storage backend. This action cannot be undone.", "This permanently deletes the storage tier. This action cannot be undone.": "This permanently deletes the storage tier. This action cannot be undone.", "This permanently deletes the tenant and all its resources. This action cannot be undone.": "This permanently deletes the tenant and all its resources. This action cannot be undone.", diff --git a/libs/ui-components/src/api/v1/private/instance-type.test.ts b/libs/ui-components/src/api/v1/private/instance-type.test.ts index 28bb06a7..a00073e6 100644 --- a/libs/ui-components/src/api/v1/private/instance-type.test.ts +++ b/libs/ui-components/src/api/v1/private/instance-type.test.ts @@ -1,16 +1,22 @@ import React, { type ReactNode, createElement } from 'react'; import { create } from '@bufbuild/protobuf'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { InstanceTypeSchema, InstanceTypeState, + InstanceTypesUpdateResponseSchema, type InstanceType as PrivateInstanceType, } from '@osac/types/private'; -import { useAdminInstanceTypes } from './instance-type'; +import { + invalidateInstanceTypesQueries, + useAdminInstanceTypes, + useDeleteInstanceType, + useUpdateInstanceType, +} from './instance-type'; import { createMockConnectTransport } from '../../../test-utils/createMockConnectTransport'; import { ApiProvider } from '../../api-context'; @@ -69,3 +75,97 @@ describe('useAdminInstanceTypes', () => { expect(queryClient.getQueryData(['v1/instance_types'])).toBeUndefined(); }); }); + +describe('useUpdateInstanceType', () => { + const mutateAndCaptureUpdate = async ( + input: Parameters['mutate']>[0], + ) => { + let captured: Record | undefined; + const transport = createMockConnectTransport( + { privateInstanceTypes: [makeInstanceType(input.id)] }, + { + onInstanceTypeUpdate: (req) => { + captured = req as unknown as Record; + return create(InstanceTypesUpdateResponseSchema, { + object: makeInstanceType(input.id), + }); + }, + }, + ); + const { wrapper } = makeWrapper(transport); + const { result } = renderHook(() => useUpdateInstanceType(), { wrapper }); + + act(() => { + result.current.mutate(input); + }); + + await waitFor(() => expect(result.current.isSuccess || result.current.isError).toBe(true)); + expect(result.current.isSuccess).toBe(true); + return captured; + }; + + it('sends the given body with a matching update mask', async () => { + const captured = await mutateAndCaptureUpdate({ + id: 'it-1', + body: { spec: { state: InstanceTypeState.DEPRECATED } }, + }); + + expect((captured?.updateMask as { paths?: string[] } | undefined)?.paths).toEqual([ + 'spec.state', + ]); + const object = captured?.object as { id?: string; spec?: { state?: InstanceTypeState } }; + expect(object.id).toBe('it-1'); + expect(object.spec?.state).toBe(InstanceTypeState.DEPRECATED); + }); + + it('sends a body targeting OBSOLETE', async () => { + const captured = await mutateAndCaptureUpdate({ + id: 'it-1', + body: { spec: { state: InstanceTypeState.OBSOLETE } }, + }); + + const object = captured?.object as { spec?: { state?: InstanceTypeState } }; + expect(object.spec?.state).toBe(InstanceTypeState.OBSOLETE); + }); + + it('sends a body targeting ACTIVE', async () => { + const captured = await mutateAndCaptureUpdate({ + id: 'it-1', + body: { spec: { state: InstanceTypeState.ACTIVE } }, + }); + + const object = captured?.object as { spec?: { state?: InstanceTypeState } }; + expect(object.spec?.state).toBe(InstanceTypeState.ACTIVE); + }); +}); + +describe('useDeleteInstanceType', () => { + it('deletes an instance type by id', async () => { + const transport = createMockConnectTransport({ + privateInstanceTypes: [makeInstanceType('it-1')], + }); + const { wrapper } = makeWrapper(transport); + const { result } = renderHook(() => useDeleteInstanceType(), { wrapper }); + + act(() => { + result.current.mutate('it-1'); + }); + + await waitFor(() => expect(result.current.isSuccess || result.current.isError).toBe(true)); + expect(result.current.isSuccess).toBe(true); + }); +}); + +describe('invalidateInstanceTypesQueries', () => { + const asApiQueryClient = (qc: QueryClient) => + qc as unknown as Parameters[0]; + + it('invalidates the private instance types list query', async () => { + const qc = new QueryClient(); + qc.setQueryData(['v1/private/instance_types'], { items: [] }); + + await invalidateInstanceTypesQueries(asApiQueryClient(qc)); + + expect(qc.getQueryState(['v1/private/instance_types'])?.isInvalidated).toBe(true); + }); +}); diff --git a/libs/ui-components/src/api/v1/private/instance-type.ts b/libs/ui-components/src/api/v1/private/instance-type.ts index 9d371922..20144b5d 100644 --- a/libs/ui-components/src/api/v1/private/instance-type.ts +++ b/libs/ui-components/src/api/v1/private/instance-type.ts @@ -1,8 +1,12 @@ -import { InstanceTypes } from '@osac/types/private'; +import { type MessageInitShape } from '@bufbuild/protobuf'; +import { useMutation } from '@tanstack/react-query'; + +import { InstanceTypeSchema, InstanceTypes } from '@osac/types/private'; import { useApiFetch } from '../../api-context'; import { type ListParams, apiQueryKey } from '../../types'; -import { useApiQuery } from '../../use-api-query'; +import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../../use-api-query'; +import { buildUpdateMaskPaths } from '../update-mask'; export const useAdminInstanceTypes = (params: ListParams = {}) => { const client = useApiFetch(InstanceTypes); @@ -12,3 +16,38 @@ export const useAdminInstanceTypes = (params: ListParams = {}) => { select: (data) => data.items, }); }; + +export const invalidateInstanceTypesQueries = (qc: ApiQueryClient) => + qc.invalidateQueries({ queryKey: apiQueryKey('v1/private/instance_types') }); + +export type UpdateInstanceTypeInput = { + id: string; + body: MessageInitShape; +}; + +export const useUpdateInstanceType = () => { + const client = useApiFetch(InstanceTypes); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: async ({ id, body }: UpdateInstanceTypeInput) => { + const resp = await client.update({ + object: { id, ...body }, + updateMask: { paths: buildUpdateMaskPaths(body as Record) }, + }); + if (!resp.object) { + throw new Error('Update response missing object'); + } + return resp.object; + }, + onSuccess: () => invalidateInstanceTypesQueries(qc), + }); +}; + +export const useDeleteInstanceType = () => { + const client = useApiFetch(InstanceTypes); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: (id: string) => client.delete({ id }), + onSuccess: () => invalidateInstanceTypesQueries(qc), + }); +}; diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx new file mode 100644 index 00000000..146f3a39 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx @@ -0,0 +1,176 @@ +import { create } from '@bufbuild/protobuf'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { + InstanceTypeSchema, + InstanceTypeState, + InstanceTypesDeleteResponseSchema, + InstanceTypesUpdateResponseSchema, + type InstanceType as PrivateInstanceType, +} from '@osac/types/private'; + +import AdminInstanceTypeActionsMenu from './AdminInstanceTypeActionsMenu'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const makeInstanceType = (state: InstanceTypeState): PrivateInstanceType => + create(InstanceTypeSchema, { + id: 'it-1', + metadata: { name: 'general-4-16' }, + spec: { cores: 4, memoryGib: 16, description: '', state }, + }); + +const renderMenu = ( + instanceType: PrivateInstanceType, + options?: Parameters[1], +) => renderWithProviders(, options); + +const openMenu = async (user: ReturnType['user']) => { + await user.click(screen.getByRole('button', { name: 'Actions for general-4-16' })); +}; + +describe('AdminInstanceTypeActionsMenu', () => { + it('exposes Deprecate and Obsolete, but not Reactivate or Delete, for an ACTIVE instance type', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.ACTIVE)); + await openMenu(user); + + expect(screen.getByRole('menuitem', { name: 'Deprecate' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Obsolete' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Reactivate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument(); + }); + + it('exposes Obsolete and Reactivate, but not Deprecate or Delete, for a DEPRECATED instance type', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.DEPRECATED)); + await openMenu(user); + + expect(screen.getByRole('menuitem', { name: 'Obsolete' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Reactivate' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Deprecate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument(); + }); + + it('exposes Deprecate, Reactivate, and Delete, but not Obsolete, for an OBSOLETE instance type', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.OBSOLETE)); + await openMenu(user); + + expect(screen.getByRole('menuitem', { name: 'Deprecate' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Reactivate' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Obsolete' })).not.toBeInTheDocument(); + }); + + it('exposes Deprecate and Obsolete, but not Reactivate or Delete, for an unset (UNSPECIFIED) state, same as ACTIVE', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.UNSPECIFIED)); + await openMenu(user); + + expect(screen.getByRole('menuitem', { name: 'Deprecate' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Obsolete' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Reactivate' })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument(); + }); + + it('sends a spec.state update targeting DEPRECATED when Deprecate is clicked', async () => { + let captured: Record | undefined; + const { user } = renderMenu(makeInstanceType(InstanceTypeState.ACTIVE), { + transportOverrides: { + onInstanceTypeUpdate: (req) => { + captured = req as unknown as Record; + return create(InstanceTypesUpdateResponseSchema, { + object: makeInstanceType(InstanceTypeState.DEPRECATED), + }); + }, + }, + }); + await openMenu(user); + await user.click(screen.getByRole('menuitem', { name: 'Deprecate' })); + + await waitFor(() => expect(captured).toBeDefined()); + const object = captured?.object as { id?: string; spec?: { state?: InstanceTypeState } }; + expect(object.id).toBe('it-1'); + expect(object.spec?.state).toBe(InstanceTypeState.DEPRECATED); + }); + + it('shows a toast with the deprecate failure title and backend message when the update fails', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.ACTIVE), { + transportOverrides: { + onInstanceTypeUpdate: () => { + throw new ConnectError('deprecation rejected', Code.FailedPrecondition); + }, + }, + }); + await openMenu(user); + await user.click(screen.getByRole('menuitem', { name: 'Deprecate' })); + + expect(await screen.findByText('Failed to deprecate instance type')).toBeInTheDocument(); + expect(screen.getByText('deprecation rejected')).toBeInTheDocument(); + }); + + it('shows a toast with the reactivate failure title when the reactivate update fails', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.DEPRECATED), { + transportOverrides: { + onInstanceTypeUpdate: () => { + throw new ConnectError('reactivation rejected', Code.FailedPrecondition); + }, + }, + }); + await openMenu(user); + await user.click(screen.getByRole('menuitem', { name: 'Reactivate' })); + + expect(await screen.findByText('Failed to reactivate instance type')).toBeInTheDocument(); + }); + + it('shows a toast with the obsolete failure title when the obsolete update fails', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.ACTIVE), { + transportOverrides: { + onInstanceTypeUpdate: () => { + throw new ConnectError('obsolete rejected', Code.FailedPrecondition); + }, + }, + }); + await openMenu(user); + await user.click(screen.getByRole('menuitem', { name: 'Obsolete' })); + + expect(await screen.findByText('Failed to mark instance type as obsolete')).toBeInTheDocument(); + }); + + it('opens a confirm modal for Delete and deletes the instance type on confirmation', async () => { + let deleteCalled = false; + const { user } = renderMenu(makeInstanceType(InstanceTypeState.OBSOLETE), { + transportOverrides: { + onInstanceTypeDelete: () => { + deleteCalled = true; + return create(InstanceTypesDeleteResponseSchema); + }, + }, + }); + await openMenu(user); + await user.click(screen.getByRole('menuitem', { name: 'Delete' })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(deleteCalled).toBe(true); + }); + + it('shows an inline alert in the confirm modal, not a toast, when delete fails', async () => { + const { user } = renderMenu(makeInstanceType(InstanceTypeState.OBSOLETE), { + transportOverrides: { + onInstanceTypeDelete: () => { + throw new ConnectError('instance type is in use', Code.FailedPrecondition); + }, + }, + }); + await openMenu(user); + await user.click(screen.getByRole('menuitem', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + const modal = screen.getByRole('dialog'); + expect(await screen.findByText('Failed to delete instance type')).toBeInTheDocument(); + expect(screen.getByText('instance type is in use')).toBeInTheDocument(); + expect(modal).toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx new file mode 100644 index 00000000..26ed5fdc --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Dropdown, DropdownItem, DropdownList, MenuToggle } from '@patternfly/react-core'; +import { EllipsisVIcon } from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +import { InstanceTypeState, type InstanceType as PrivateInstanceType } from '@osac/types/private'; + +import InstanceTypeDeleteConfirmModal from './InstanceTypeDeleteConfirmModal'; +import { useInstanceTypeLifecycleAction } from './useInstanceTypeLifecycleAction'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface AdminInstanceTypeActionsMenuProps { + instanceType: PrivateInstanceType; +} + +const AdminInstanceTypeActionsMenu = ({ instanceType }: AdminInstanceTypeActionsMenuProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const { runLifecycleAction } = useInstanceTypeLifecycleAction(); + + const state = instanceType.spec?.state || InstanceTypeState.ACTIVE; + const canDeprecate = state !== InstanceTypeState.DEPRECATED; + const canObsolete = state !== InstanceTypeState.OBSOLETE; + const canReactivate = state !== InstanceTypeState.ACTIVE; + const canDelete = state === InstanceTypeState.OBSOLETE; + + const name = instanceType.metadata?.name || instanceType.id; + + return ( + <> + {deleteOpen && ( + setDeleteOpen(false)} + onSuccess={() => setDeleteOpen(false)} + /> + )} + ( + setOpen((o) => !o)} + aria-label={t('Actions for {{name}}', { name })} + > + + + )} + popperProps={{ position: 'right' }} + > + + {canDeprecate && ( + { + runLifecycleAction(instanceType.id, InstanceTypeState.DEPRECATED); + setOpen(false); + }} + > + {t('Deprecate')} + + )} + {canObsolete && ( + { + runLifecycleAction(instanceType.id, InstanceTypeState.OBSOLETE); + setOpen(false); + }} + > + {t('Obsolete')} + + )} + {canReactivate && ( + { + runLifecycleAction(instanceType.id, InstanceTypeState.ACTIVE); + setOpen(false); + }} + > + {t('Reactivate')} + + )} + {canDelete && ( + { + setDeleteOpen(true); + setOpen(false); + }} + > + {t('Delete')} + + )} + + + + ); +}; + +export default AdminInstanceTypeActionsMenu; diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.test.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.test.tsx index 85b0e420..79506826 100644 --- a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.test.tsx +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeListPage.test.tsx @@ -1,11 +1,14 @@ import { Route, Routes } from 'react-router-dom'; import { create } from '@bufbuild/protobuf'; -import { screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { InstanceTypeSchema, InstanceTypeState, + InstanceTypesDeleteResponseSchema, + InstanceTypesListResponseSchema, + InstanceTypesUpdateResponseSchema, type InstanceType as PrivateInstanceType, } from '@osac/types/private'; import { mockQueryResult } from '@osac/ui-components/test-utils/query'; @@ -13,11 +16,16 @@ import { mockQueryResult } from '@osac/ui-components/test-utils/query'; import AdminInstanceTypeListPage from './AdminInstanceTypeListPage'; import { renderWithProviders } from '../../test-utils/TestProviders'; -vi.mock('@osac/ui-components/api/v1/private/instance-type', () => ({ - useAdminInstanceTypes: vi.fn(), -})); +vi.mock('@osac/ui-components/api/v1/private/instance-type', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, useAdminInstanceTypes: vi.fn() }; +}); const { useAdminInstanceTypes } = await import('@osac/ui-components/api/v1/private/instance-type'); +const { useAdminInstanceTypes: useAdminInstanceTypesActual } = await vi.importActual< + typeof import('@osac/ui-components/api/v1/private/instance-type') +>('@osac/ui-components/api/v1/private/instance-type'); const longDescription = 'A provider-curated general-purpose instance type for sustained workloads that need predictable CPU and memory capacity, room for sidecar processes, and enough headroom for bursty background tasks without immediately resizing the virtual machine.'; @@ -56,6 +64,10 @@ const renderPageWithCreateRoute = () => ); describe('AdminInstanceTypeListPage', () => { + afterEach(() => { + vi.mocked(useAdminInstanceTypes).mockReset(); + }); + it('renders the required columns and lifecycle labels for populated data', () => { vi.mocked(useAdminInstanceTypes).mockReturnValue( mockQueryResult({ @@ -78,6 +90,7 @@ describe('AdminInstanceTypeListPage', () => { 'Memory (GiB)', 'Description', 'Created', + '', ]); expect(screen.getByText('instance-type-active-1')).toBeInTheDocument(); expect(screen.getByText('active-1 description')).toBeInTheDocument(); @@ -155,4 +168,74 @@ describe('AdminInstanceTypeListPage', () => { expect(screen.getByRole('heading', { name: 'Create instance type page' })).toBeInTheDocument(); }); + + it('sends the deprecate request and re-fetches the list to reflect the new state', async () => { + vi.mocked(useAdminInstanceTypes).mockImplementation(useAdminInstanceTypesActual); + const items = [makeInstanceType('active-1', InstanceTypeState.ACTIVE)]; + let captured: Record | undefined; + let listCalls = 0; + + const { user } = renderWithProviders(, { + apiFixtures: { privateInstanceTypes: items }, + transportOverrides: { + onInstanceTypeList: () => { + listCalls += 1; + return create(InstanceTypesListResponseSchema, { + items, + size: items.length, + total: items.length, + }); + }, + onInstanceTypeUpdate: (req) => { + captured = req as unknown as Record; + items[0] = makeInstanceType('active-1', InstanceTypeState.DEPRECATED); + return create(InstanceTypesUpdateResponseSchema, { object: items[0] }); + }, + }, + }); + + await screen.findByText('instance-type-active-1'); + const listCallsBeforeAction = listCalls; + + await user.click(screen.getByRole('button', { name: 'Actions for instance-type-active-1' })); + await user.click(screen.getByRole('menuitem', { name: 'Deprecate' })); + + await waitFor(() => expect(screen.getByText('Deprecated')).toBeInTheDocument()); + const object = captured?.object as { id?: string; spec?: { state?: InstanceTypeState } }; + expect(object.id).toBe('active-1'); + expect(object.spec?.state).toBe(InstanceTypeState.DEPRECATED); + expect(listCalls).toBeGreaterThan(listCallsBeforeAction); + }); + + it('sends the delete request and removes the row after the list re-fetches', async () => { + vi.mocked(useAdminInstanceTypes).mockImplementation(useAdminInstanceTypesActual); + const items = [makeInstanceType('obsolete-1', InstanceTypeState.OBSOLETE)]; + let deleteCalled = false; + + const { user } = renderWithProviders(, { + apiFixtures: { privateInstanceTypes: items }, + transportOverrides: { + onInstanceTypeDelete: (req) => { + deleteCalled = true; + const index = items.findIndex((item) => item.id === req.id); + if (index >= 0) { + items.splice(index, 1); + } + return create(InstanceTypesDeleteResponseSchema); + }, + }, + }); + + await screen.findByText('instance-type-obsolete-1'); + + await user.click(screen.getByRole('button', { name: 'Actions for instance-type-obsolete-1' })); + await user.click(screen.getByRole('menuitem', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => + expect(screen.queryByText('instance-type-obsolete-1')).not.toBeInTheDocument(), + ); + expect(deleteCalled).toBe(true); + expect(screen.getByText('No instance types yet.')).toBeInTheDocument(); + }); }); diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeTable.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeTable.tsx index 47831a1b..5c2d9721 100644 --- a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeTable.tsx +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeTable.tsx @@ -4,6 +4,7 @@ import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import type { InstanceType as PrivateInstanceType } from '@osac/types/private'; +import AdminInstanceTypeActionsMenu from './AdminInstanceTypeActionsMenu'; import InstanceTypeLifecycleLabel from './InstanceTypeLifecycleLabel'; import { useTranslation } from '../../hooks/useTranslation'; import { Timestamp } from '../Primitives/Timestamp'; @@ -17,7 +18,7 @@ const CPU_CORES_COLUMN_WIDTH = 10; const MEMORY_COLUMN_WIDTH = 10; const DESCRIPTION_COLUMN_WIDTH = 40; const CREATED_COLUMN_WIDTH = 15; -const EMPTY_STATE_COLUMN_SPAN = 6; +const EMPTY_STATE_COLUMN_SPAN = 7; interface AdminInstanceTypeTableProps { instanceTypes: PrivateInstanceType[]; @@ -36,6 +37,7 @@ const AdminInstanceTypeTable = ({ instanceTypes }: AdminInstanceTypeTableProps) {t('Memory (GiB)')} {t('Description')} {t('Created')} + @@ -83,6 +85,9 @@ const AdminInstanceTypeTable = ({ instanceTypes }: AdminInstanceTypeTableProps) + + + )) )} diff --git a/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx b/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx new file mode 100644 index 00000000..9fa7650d --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx @@ -0,0 +1,86 @@ +import { + Alert, + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Stack, + StackItem, +} from '@patternfly/react-core'; + +import type { InstanceType as PrivateInstanceType } from '@osac/types/private'; + +import { useDeleteInstanceType } from '../../api/v1/private/instance-type'; +import { useTranslation } from '../../hooks/useTranslation'; +import { getErrorMessage } from '../../utils/error'; + +interface InstanceTypeDeleteConfirmModalProps { + instanceType: PrivateInstanceType; + onClose: () => void; + onSuccess: () => void; +} + +const InstanceTypeDeleteConfirmModal = ({ + instanceType, + onClose, + onSuccess, +}: InstanceTypeDeleteConfirmModalProps) => { + const { t } = useTranslation(); + const deleteInstanceType = useDeleteInstanceType(); + + const onDelete = () => { + deleteInstanceType.reset(); + deleteInstanceType.mutate(instanceType.id, { onSuccess }); + }; + + return ( + + + + + + {t('This permanently deletes the instance type. This action cannot be undone.')} + + {deleteInstanceType.error && ( + + + {getErrorMessage(deleteInstanceType.error)} + + + )} + + + + + + + + ); +}; + +export default InstanceTypeDeleteConfirmModal; diff --git a/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts b/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts new file mode 100644 index 00000000..54341eb0 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts @@ -0,0 +1,45 @@ +import type { TFunction } from 'i18next'; + +import { InstanceTypeState } from '@osac/types/private'; + +import { useUpdateInstanceType } from '../../api/v1/private/instance-type'; +import { useTranslation } from '../../hooks/useTranslation'; +import { getErrorMessage } from '../../utils/error'; +import { useToast } from '../Toast/useToast'; + +export type InstanceTypeLifecycleAction = Exclude; + +const getLifecycleErrorTitle = (t: TFunction, action: InstanceTypeLifecycleAction): string => { + switch (action) { + case InstanceTypeState.DEPRECATED: + return t('Failed to deprecate instance type'); + case InstanceTypeState.OBSOLETE: + return t('Failed to mark instance type as obsolete'); + case InstanceTypeState.ACTIVE: + return t('Failed to reactivate instance type'); + } +}; + +/** Runs an instance type lifecycle transition and surfaces a toast if it fails. */ +export const useInstanceTypeLifecycleAction = () => { + const { t } = useTranslation(); + const { addToast } = useToast(); + const updateInstanceType = useUpdateInstanceType(); + + const runLifecycleAction = (instanceTypeId: string, action: InstanceTypeLifecycleAction) => { + updateInstanceType.mutate( + { id: instanceTypeId, body: { spec: { state: action } } }, + { + onError: (error) => { + addToast({ + variant: 'danger', + title: getLifecycleErrorTitle(t, action), + description: getErrorMessage(error), + }); + }, + }, + ); + }; + + return { runLifecycleAction }; +}; diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index 0f29794f..22116b4e 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -33,6 +33,12 @@ import { VirtualNetworks, } from '@osac/types'; import type { + InstanceTypesDeleteRequest, + InstanceTypesDeleteResponse, + InstanceTypesListRequest, + InstanceTypesListResponse, + InstanceTypesUpdateRequest, + InstanceTypesUpdateResponse, InstanceType as PrivateInstanceType, Tenant as PrivateTenant, StorageBackend, @@ -167,6 +173,9 @@ export type MockTransportOverrides = { onStorageTierDelete?: ( req: StorageTiersDeleteRequest, ) => MessageInitShape; + onInstanceTypeList?: (req: InstanceTypesListRequest) => InstanceTypesListResponse; + onInstanceTypeUpdate?: (req: InstanceTypesUpdateRequest) => InstanceTypesUpdateResponse; + onInstanceTypeDelete?: (req: InstanceTypesDeleteRequest) => InstanceTypesDeleteResponse; }; export const createMockConnectTransport = ( @@ -390,14 +399,31 @@ export const createMockConnectTransport = ( }); router.service(PrivateInstanceTypes, { - list: () => ({ - items: privateInstanceTypes, - size: privateInstanceTypes.length, - total: privateInstanceTypes.length, - }), + list: (req) => { + if (overrides.onInstanceTypeList) { + return overrides.onInstanceTypeList(req); + } + return { + items: privateInstanceTypes, + size: privateInstanceTypes.length, + total: privateInstanceTypes.length, + }; + }, get: (req) => ({ object: privateInstanceTypes.find((item) => item.id === req.id), }), + update: (req) => { + if (overrides.onInstanceTypeUpdate) { + return overrides.onInstanceTypeUpdate(req); + } + return { object: req.object }; + }, + delete: (req) => { + if (overrides.onInstanceTypeDelete) { + return overrides.onInstanceTypeDelete(req); + } + return {}; + }, }); router.service(PrivateTenants, {