From febaa4da6856600a9e384e36e5fd0edacf2e26b1 Mon Sep 17 00:00:00 2001 From: batzionb Date: Mon, 10 Aug 2026 18:04:41 +0300 Subject: [PATCH 1/8] OSAC-3781: add instance type lifecycle and delete mutation hooks Add useUpdateInstanceTypeState and useDeleteInstanceType, parameterized by an InstanceTypeLifecycleAction union mirroring usePatchComputeInstance, plus mock transport support for the InstanceTypes Update/Delete RPCs. Assisted-by: Claude Code Signed-off-by: batzionb --- .../src/api/v1/private/instance-type.test.ts | 95 ++++++++++++++++++- .../src/api/v1/private/instance-type.ts | 58 ++++++++++- .../test-utils/createMockConnectTransport.ts | 36 ++++++- 3 files changed, 180 insertions(+), 9 deletions(-) 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..318155b9 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, + useUpdateInstanceTypeState, +} from './instance-type'; import { createMockConnectTransport } from '../../../test-utils/createMockConnectTransport'; import { ApiProvider } from '../../api-context'; @@ -69,3 +75,88 @@ describe('useAdminInstanceTypes', () => { expect(queryClient.getQueryData(['v1/instance_types'])).toBeUndefined(); }); }); + +describe('useUpdateInstanceTypeState', () => { + 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(() => useUpdateInstanceTypeState(), { 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 a spec.state mask with the DEPRECATED target for the deprecate action', async () => { + const captured = await mutateAndCaptureUpdate({ id: 'it-1', action: 'deprecate' }); + + 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 spec.state mask with the OBSOLETE target for the obsolete action', async () => { + const captured = await mutateAndCaptureUpdate({ id: 'it-1', action: 'obsolete' }); + + const object = captured?.object as { spec?: { state?: InstanceTypeState } }; + expect(object.spec?.state).toBe(InstanceTypeState.OBSOLETE); + }); + + it('sends a spec.state mask with the ACTIVE target for the reactivate action', async () => { + const captured = await mutateAndCaptureUpdate({ id: 'it-1', action: 'reactivate' }); + + 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..f147c89a 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, InstanceTypeState, 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,53 @@ export const useAdminInstanceTypes = (params: ListParams = {}) => { select: (data) => data.items, }); }; + +export const invalidateInstanceTypesQueries = (qc: ApiQueryClient) => + qc.invalidateQueries({ queryKey: apiQueryKey('v1/private/instance_types') }); + +export type InstanceTypeLifecycleAction = 'deprecate' | 'obsolete' | 'reactivate'; + +const LIFECYCLE_ACTION_STATE: Record = { + deprecate: InstanceTypeState.DEPRECATED, + obsolete: InstanceTypeState.OBSOLETE, + reactivate: InstanceTypeState.ACTIVE, +}; + +const buildLifecycleStateBody = ( + action: InstanceTypeLifecycleAction, +): MessageInitShape => ({ + spec: { state: LIFECYCLE_ACTION_STATE[action] }, +}); + +export type UpdateInstanceTypeStateInput = { + id: string; + action: InstanceTypeLifecycleAction; +}; + +export const useUpdateInstanceTypeState = () => { + const client = useApiFetch(InstanceTypes); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: async ({ id, action }: UpdateInstanceTypeStateInput) => { + const body = buildLifecycleStateBody(action); + 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/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, { From 02fe14c82e2d358fb0dd7ee831f64108d5db7136 Mon Sep 17 00:00:00 2001 From: batzionb Date: Mon, 10 Aug 2026 18:08:20 +0300 Subject: [PATCH 2/8] OSAC-3781: add instance type lifecycle actions menu and delete confirmation Add AdminInstanceTypeActionsMenu with state-gated Deprecate/Obsolete/ Reactivate/Delete row actions: lifecycle transitions mutate directly and surface failures via toast (mirroring useVmPowerAction), while Delete is gated behind a confirm modal with an inline error (mirroring VmDeleteConfirmModal). Assisted-by: Claude Code Signed-off-by: batzionb --- libs/i18n/locales/en/translation.json | 7 + .../AdminInstanceTypeActionsMenu.test.tsx | 156 ++++++++++++++++++ .../AdminInstanceTypeActionsMenu.tsx | 104 ++++++++++++ .../InstanceTypeDeleteConfirmModal.tsx | 81 +++++++++ .../instanceTypeLifecycleErrorTitle.test.ts | 28 ++++ .../instanceTypeLifecycleErrorTitle.ts | 17 ++ .../useInstanceTypeLifecycleAction.ts | 32 ++++ 7 files changed, 425 insertions(+) create mode 100644 libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx create mode 100644 libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx create mode 100644 libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx create mode 100644 libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.test.ts create mode 100644 libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts create mode 100644 libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts 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/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx new file mode 100644 index 00000000..217be293 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx @@ -0,0 +1,156 @@ +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('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('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..483d417e --- /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; + 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, 'deprecate'); + setOpen(false); + }} + > + {t('Deprecate')} + + )} + {canObsolete && ( + { + runLifecycleAction(instanceType.id, 'obsolete'); + setOpen(false); + }} + > + {t('Obsolete')} + + )} + {canReactivate && ( + { + runLifecycleAction(instanceType.id, 'reactivate'); + setOpen(false); + }} + > + {t('Reactivate')} + + )} + {canDelete && ( + { + setDeleteOpen(true); + setOpen(false); + }} + > + {t('Delete')} + + )} + + + + ); +}; + +export default AdminInstanceTypeActionsMenu; 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..16d7a4fd --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx @@ -0,0 +1,81 @@ +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/instanceTypeLifecycleErrorTitle.test.ts b/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.test.ts new file mode 100644 index 00000000..58e914e9 --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.test.ts @@ -0,0 +1,28 @@ +import i18next from 'i18next'; +import { describe, expect, it } from 'vitest'; + +import { getInstanceTypeLifecycleErrorTitle } from './instanceTypeLifecycleErrorTitle'; + +const i18nInstance = i18next.createInstance(); +i18nInstance.init({ initImmediate: false, lng: 'en', fallbackLng: 'en', resources: {} }); +const t = i18nInstance.t.bind(i18nInstance); + +describe('getInstanceTypeLifecycleErrorTitle', () => { + it('returns the deprecate failure title', () => { + expect(getInstanceTypeLifecycleErrorTitle(t, 'deprecate')).toBe( + 'Failed to deprecate instance type', + ); + }); + + it('returns the obsolete failure title', () => { + expect(getInstanceTypeLifecycleErrorTitle(t, 'obsolete')).toBe( + 'Failed to mark instance type as obsolete', + ); + }); + + it('returns the reactivate failure title', () => { + expect(getInstanceTypeLifecycleErrorTitle(t, 'reactivate')).toBe( + 'Failed to reactivate instance type', + ); + }); +}); diff --git a/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts b/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts new file mode 100644 index 00000000..b003a6da --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts @@ -0,0 +1,17 @@ +import type { TFunction } from 'i18next'; + +import type { InstanceTypeLifecycleAction } from '../../api/v1/private/instance-type'; + +export const getInstanceTypeLifecycleErrorTitle = ( + t: TFunction, + action: InstanceTypeLifecycleAction, +): string => { + switch (action) { + case 'deprecate': + return t('Failed to deprecate instance type'); + case 'obsolete': + return t('Failed to mark instance type as obsolete'); + case 'reactivate': + return t('Failed to reactivate instance type'); + } +}; 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..b6a47a5d --- /dev/null +++ b/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts @@ -0,0 +1,32 @@ +import { getInstanceTypeLifecycleErrorTitle } from './instanceTypeLifecycleErrorTitle'; +import { + type InstanceTypeLifecycleAction, + useUpdateInstanceTypeState, +} from '../../api/v1/private/instance-type'; +import { useTranslation } from '../../hooks/useTranslation'; +import { getErrorMessage } from '../../utils/error'; +import { useToast } from '../Toast/useToast'; + +/** Runs an instance type lifecycle transition and surfaces a toast if it fails. */ +export const useInstanceTypeLifecycleAction = () => { + const { t } = useTranslation(); + const { addToast } = useToast(); + const updateState = useUpdateInstanceTypeState(); + + const runLifecycleAction = (instanceTypeId: string, action: InstanceTypeLifecycleAction) => { + updateState.mutate( + { id: instanceTypeId, action }, + { + onError: (error) => { + addToast({ + variant: 'danger', + title: getInstanceTypeLifecycleErrorTitle(t, action), + description: getErrorMessage(error), + }); + }, + }, + ); + }; + + return { runLifecycleAction }; +}; From 58603b3ff685dac6c447f15d3924d8c0d8525209 Mon Sep 17 00:00:00 2001 From: batzionb Date: Mon, 10 Aug 2026 18:09:10 +0300 Subject: [PATCH 3/8] OSAC-3781: fix formatting in instance type delete confirmation and tests Assisted-by: Claude Code Signed-off-by: batzionb --- .../InstanceType/AdminInstanceTypeActionsMenu.test.tsx | 6 +----- .../InstanceType/InstanceTypeDeleteConfirmModal.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx index 217be293..70031530 100644 --- a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx @@ -128,11 +128,7 @@ describe('AdminInstanceTypeActionsMenu', () => { await user.click(screen.getByRole('button', { name: 'Delete' })); - await waitFor(() => - expect( - screen.queryByRole('dialog'), - ).not.toBeInTheDocument(), - ); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(deleteCalled).toBe(true); }); diff --git a/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx b/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx index 16d7a4fd..9fa7650d 100644 --- a/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx +++ b/libs/ui-components/src/components/InstanceType/InstanceTypeDeleteConfirmModal.tsx @@ -70,7 +70,12 @@ const InstanceTypeDeleteConfirmModal = ({ > {t('Delete')} - From 4a8cb269ad53d94539fda948dec7780294871689 Mon Sep 17 00:00:00 2001 From: batzionb Date: Mon, 10 Aug 2026 18:11:40 +0300 Subject: [PATCH 4/8] OSAC-3781: wire lifecycle and delete row actions into the instance type table Add an Actions column rendering AdminInstanceTypeActionsMenu per row, and add integration tests verifying the update/delete request sent from the row menu and that the admin instance types list re-fetches after a successful mutation. Assisted-by: Claude Code Signed-off-by: batzionb --- .../AdminInstanceTypeListPage.test.tsx | 93 ++++++++++++++++++- .../InstanceType/AdminInstanceTypeTable.tsx | 7 +- 2 files changed, 94 insertions(+), 6 deletions(-) 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) + + + )) )} From 0d4574bb946c9f60fd82d84846c75980ab72f001 Mon Sep 17 00:00:00 2001 From: batzionb Date: Mon, 10 Aug 2026 21:50:00 +0300 Subject: [PATCH 5/8] OSAC-3781: address validation review findings Treat an unset (UNSPECIFIED) instance type state the same as ACTIVE when gating row actions, matching the backend, which always defaults state to ACTIVE on creation. Assisted-by: Claude Code Signed-off-by: batzionb --- .../InstanceType/AdminInstanceTypeActionsMenu.test.tsx | 10 ++++++++++ .../InstanceType/AdminInstanceTypeActionsMenu.tsx | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx index 70031530..a38777f0 100644 --- a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx @@ -61,6 +61,16 @@ describe('AdminInstanceTypeActionsMenu', () => { 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), { diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx index 483d417e..cb4670e1 100644 --- a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx @@ -18,7 +18,7 @@ const AdminInstanceTypeActionsMenu = ({ instanceType }: AdminInstanceTypeActions const [deleteOpen, setDeleteOpen] = useState(false); const { runLifecycleAction } = useInstanceTypeLifecycleAction(); - const state = instanceType.spec?.state; + const state = instanceType.spec?.state || InstanceTypeState.ACTIVE; const canDeprecate = state !== InstanceTypeState.DEPRECATED; const canObsolete = state !== InstanceTypeState.OBSOLETE; const canReactivate = state !== InstanceTypeState.ACTIVE; From 4abc2fad38dc48ae905825249ed8983ef991ce20 Mon Sep 17 00:00:00 2001 From: batzionb Date: Tue, 11 Aug 2026 13:11:20 +0300 Subject: [PATCH 6/8] OSAC-3781: generalize instance type update hook per review feedback Replace the action-parameterized useUpdateInstanceTypeState with a generic useUpdateInstanceType({ id, body }), matching the dominant existing pattern (useUpdateIdentityProvider, useUpdateSecurityGroup, useUpdateStorageTier, useUpdateStorageBackend) instead of the compute-instance power-action outlier this PR had mirrored. The action->state mapping and error-title lookup move into useInstanceTypeLifecycleAction, the hook's only consumer, removing the now-pointless single-line buildLifecycleStateBody wrapper and the separately exported instanceTypeLifecycleErrorTitle module. Assisted-by: Claude Code Signed-off-by: batzionb --- .../instanceTypeLifecycleErrorTitle.test.ts | 28 ------------------- .../instanceTypeLifecycleErrorTitle.ts | 17 ----------- 2 files changed, 45 deletions(-) delete mode 100644 libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.test.ts delete mode 100644 libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts diff --git a/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.test.ts b/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.test.ts deleted file mode 100644 index 58e914e9..00000000 --- a/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import i18next from 'i18next'; -import { describe, expect, it } from 'vitest'; - -import { getInstanceTypeLifecycleErrorTitle } from './instanceTypeLifecycleErrorTitle'; - -const i18nInstance = i18next.createInstance(); -i18nInstance.init({ initImmediate: false, lng: 'en', fallbackLng: 'en', resources: {} }); -const t = i18nInstance.t.bind(i18nInstance); - -describe('getInstanceTypeLifecycleErrorTitle', () => { - it('returns the deprecate failure title', () => { - expect(getInstanceTypeLifecycleErrorTitle(t, 'deprecate')).toBe( - 'Failed to deprecate instance type', - ); - }); - - it('returns the obsolete failure title', () => { - expect(getInstanceTypeLifecycleErrorTitle(t, 'obsolete')).toBe( - 'Failed to mark instance type as obsolete', - ); - }); - - it('returns the reactivate failure title', () => { - expect(getInstanceTypeLifecycleErrorTitle(t, 'reactivate')).toBe( - 'Failed to reactivate instance type', - ); - }); -}); diff --git a/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts b/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts deleted file mode 100644 index b003a6da..00000000 --- a/libs/ui-components/src/components/InstanceType/instanceTypeLifecycleErrorTitle.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TFunction } from 'i18next'; - -import type { InstanceTypeLifecycleAction } from '../../api/v1/private/instance-type'; - -export const getInstanceTypeLifecycleErrorTitle = ( - t: TFunction, - action: InstanceTypeLifecycleAction, -): string => { - switch (action) { - case 'deprecate': - return t('Failed to deprecate instance type'); - case 'obsolete': - return t('Failed to mark instance type as obsolete'); - case 'reactivate': - return t('Failed to reactivate instance type'); - } -}; From feeaed9e8b9c54f81ff166b366703fa4945e6f50 Mon Sep 17 00:00:00 2001 From: batzionb Date: Tue, 11 Aug 2026 13:11:49 +0300 Subject: [PATCH 7/8] OSAC-3781: wire up the generalized instance type update hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the previous commit — the source changes for the useUpdateInstanceType generalization were left unstaged. Assisted-by: Claude Code Signed-off-by: batzionb --- .../src/api/v1/private/instance-type.test.ts | 29 ++++++++++----- .../src/api/v1/private/instance-type.ts | 25 +++---------- .../AdminInstanceTypeActionsMenu.test.tsx | 14 +++++++ .../useInstanceTypeLifecycleAction.ts | 37 ++++++++++++++----- 4 files changed, 66 insertions(+), 39 deletions(-) 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 318155b9..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 @@ -15,7 +15,7 @@ import { invalidateInstanceTypesQueries, useAdminInstanceTypes, useDeleteInstanceType, - useUpdateInstanceTypeState, + useUpdateInstanceType, } from './instance-type'; import { createMockConnectTransport } from '../../../test-utils/createMockConnectTransport'; import { ApiProvider } from '../../api-context'; @@ -76,9 +76,9 @@ describe('useAdminInstanceTypes', () => { }); }); -describe('useUpdateInstanceTypeState', () => { +describe('useUpdateInstanceType', () => { const mutateAndCaptureUpdate = async ( - input: Parameters['mutate']>[0], + input: Parameters['mutate']>[0], ) => { let captured: Record | undefined; const transport = createMockConnectTransport( @@ -93,7 +93,7 @@ describe('useUpdateInstanceTypeState', () => { }, ); const { wrapper } = makeWrapper(transport); - const { result } = renderHook(() => useUpdateInstanceTypeState(), { wrapper }); + const { result } = renderHook(() => useUpdateInstanceType(), { wrapper }); act(() => { result.current.mutate(input); @@ -104,8 +104,11 @@ describe('useUpdateInstanceTypeState', () => { return captured; }; - it('sends a spec.state mask with the DEPRECATED target for the deprecate action', async () => { - const captured = await mutateAndCaptureUpdate({ id: 'it-1', action: 'deprecate' }); + 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', @@ -115,15 +118,21 @@ describe('useUpdateInstanceTypeState', () => { expect(object.spec?.state).toBe(InstanceTypeState.DEPRECATED); }); - it('sends a spec.state mask with the OBSOLETE target for the obsolete action', async () => { - const captured = await mutateAndCaptureUpdate({ id: 'it-1', action: 'obsolete' }); + 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 spec.state mask with the ACTIVE target for the reactivate action', async () => { - const captured = await mutateAndCaptureUpdate({ id: 'it-1', action: 'reactivate' }); + 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); 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 f147c89a..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,7 +1,7 @@ import { type MessageInitShape } from '@bufbuild/protobuf'; import { useMutation } from '@tanstack/react-query'; -import { InstanceTypeSchema, InstanceTypeState, InstanceTypes } from '@osac/types/private'; +import { InstanceTypeSchema, InstanceTypes } from '@osac/types/private'; import { useApiFetch } from '../../api-context'; import { type ListParams, apiQueryKey } from '../../types'; @@ -20,31 +20,16 @@ export const useAdminInstanceTypes = (params: ListParams = {}) => { export const invalidateInstanceTypesQueries = (qc: ApiQueryClient) => qc.invalidateQueries({ queryKey: apiQueryKey('v1/private/instance_types') }); -export type InstanceTypeLifecycleAction = 'deprecate' | 'obsolete' | 'reactivate'; - -const LIFECYCLE_ACTION_STATE: Record = { - deprecate: InstanceTypeState.DEPRECATED, - obsolete: InstanceTypeState.OBSOLETE, - reactivate: InstanceTypeState.ACTIVE, -}; - -const buildLifecycleStateBody = ( - action: InstanceTypeLifecycleAction, -): MessageInitShape => ({ - spec: { state: LIFECYCLE_ACTION_STATE[action] }, -}); - -export type UpdateInstanceTypeStateInput = { +export type UpdateInstanceTypeInput = { id: string; - action: InstanceTypeLifecycleAction; + body: MessageInitShape; }; -export const useUpdateInstanceTypeState = () => { +export const useUpdateInstanceType = () => { const client = useApiFetch(InstanceTypes); const qc = useApiQueryClient(); return useMutation({ - mutationFn: async ({ id, action }: UpdateInstanceTypeStateInput) => { - const body = buildLifecycleStateBody(action); + mutationFn: async ({ id, body }: UpdateInstanceTypeInput) => { const resp = await client.update({ object: { id, ...body }, updateMask: { paths: buildUpdateMaskPaths(body as Record) }, diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx index a38777f0..146f3a39 100644 --- a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.test.tsx @@ -121,6 +121,20 @@ describe('AdminInstanceTypeActionsMenu', () => { 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), { diff --git a/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts b/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts index b6a47a5d..d3b3f748 100644 --- a/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts +++ b/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts @@ -1,26 +1,45 @@ -import { getInstanceTypeLifecycleErrorTitle } from './instanceTypeLifecycleErrorTitle'; -import { - type InstanceTypeLifecycleAction, - useUpdateInstanceTypeState, -} from '../../api/v1/private/instance-type'; +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 = 'deprecate' | 'obsolete' | 'reactivate'; + +const LIFECYCLE_ACTION_STATE: Record = { + deprecate: InstanceTypeState.DEPRECATED, + obsolete: InstanceTypeState.OBSOLETE, + reactivate: InstanceTypeState.ACTIVE, +}; + +const getLifecycleErrorTitle = (t: TFunction, action: InstanceTypeLifecycleAction): string => { + switch (action) { + case 'deprecate': + return t('Failed to deprecate instance type'); + case 'obsolete': + return t('Failed to mark instance type as obsolete'); + case 'reactivate': + 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 updateState = useUpdateInstanceTypeState(); + const updateInstanceType = useUpdateInstanceType(); const runLifecycleAction = (instanceTypeId: string, action: InstanceTypeLifecycleAction) => { - updateState.mutate( - { id: instanceTypeId, action }, + updateInstanceType.mutate( + { id: instanceTypeId, body: { spec: { state: LIFECYCLE_ACTION_STATE[action] } } }, { onError: (error) => { addToast({ variant: 'danger', - title: getInstanceTypeLifecycleErrorTitle(t, action), + title: getLifecycleErrorTitle(t, action), description: getErrorMessage(error), }); }, From f25d7b8b61727e413a100fc60b8c8ee4f370c40f Mon Sep 17 00:00:00 2001 From: batzionb Date: Tue, 11 Aug 2026 13:53:28 +0300 Subject: [PATCH 8/8] OSAC-3781: use InstanceTypeState directly for lifecycle actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the string-literal InstanceTypeLifecycleAction union in favor of Exclude, removing the LIFECYCLE_ACTION_STATE lookup table entirely — the action is the target state. Excluding UNSPECIFIED keeps the type narrower than the raw enum, so it can't represent a non-action, and getLifecycleErrorTitle's switch stays exhaustively checked. Assisted-by: Claude Code Signed-off-by: batzionb --- .../AdminInstanceTypeActionsMenu.tsx | 6 +++--- .../useInstanceTypeLifecycleAction.ts | 16 +++++----------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx index cb4670e1..26ed5fdc 100644 --- a/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx +++ b/libs/ui-components/src/components/InstanceType/AdminInstanceTypeActionsMenu.tsx @@ -55,7 +55,7 @@ const AdminInstanceTypeActionsMenu = ({ instanceType }: AdminInstanceTypeActions { - runLifecycleAction(instanceType.id, 'deprecate'); + runLifecycleAction(instanceType.id, InstanceTypeState.DEPRECATED); setOpen(false); }} > @@ -66,7 +66,7 @@ const AdminInstanceTypeActionsMenu = ({ instanceType }: AdminInstanceTypeActions { - runLifecycleAction(instanceType.id, 'obsolete'); + runLifecycleAction(instanceType.id, InstanceTypeState.OBSOLETE); setOpen(false); }} > @@ -77,7 +77,7 @@ const AdminInstanceTypeActionsMenu = ({ instanceType }: AdminInstanceTypeActions { - runLifecycleAction(instanceType.id, 'reactivate'); + runLifecycleAction(instanceType.id, InstanceTypeState.ACTIVE); setOpen(false); }} > diff --git a/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts b/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts index d3b3f748..54341eb0 100644 --- a/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts +++ b/libs/ui-components/src/components/InstanceType/useInstanceTypeLifecycleAction.ts @@ -7,21 +7,15 @@ import { useTranslation } from '../../hooks/useTranslation'; import { getErrorMessage } from '../../utils/error'; import { useToast } from '../Toast/useToast'; -export type InstanceTypeLifecycleAction = 'deprecate' | 'obsolete' | 'reactivate'; - -const LIFECYCLE_ACTION_STATE: Record = { - deprecate: InstanceTypeState.DEPRECATED, - obsolete: InstanceTypeState.OBSOLETE, - reactivate: InstanceTypeState.ACTIVE, -}; +export type InstanceTypeLifecycleAction = Exclude; const getLifecycleErrorTitle = (t: TFunction, action: InstanceTypeLifecycleAction): string => { switch (action) { - case 'deprecate': + case InstanceTypeState.DEPRECATED: return t('Failed to deprecate instance type'); - case 'obsolete': + case InstanceTypeState.OBSOLETE: return t('Failed to mark instance type as obsolete'); - case 'reactivate': + case InstanceTypeState.ACTIVE: return t('Failed to reactivate instance type'); } }; @@ -34,7 +28,7 @@ export const useInstanceTypeLifecycleAction = () => { const runLifecycleAction = (instanceTypeId: string, action: InstanceTypeLifecycleAction) => { updateInstanceType.mutate( - { id: instanceTypeId, body: { spec: { state: LIFECYCLE_ACTION_STATE[action] } } }, + { id: instanceTypeId, body: { spec: { state: action } } }, { onError: (error) => { addToast({