Skip to content
7 changes: 7 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
104 changes: 102 additions & 2 deletions libs/ui-components/src/api/v1/private/instance-type.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -69,3 +75,97 @@ describe('useAdminInstanceTypes', () => {
expect(queryClient.getQueryData(['v1/instance_types'])).toBeUndefined();
});
});

describe('useUpdateInstanceType', () => {
const mutateAndCaptureUpdate = async (
input: Parameters<ReturnType<typeof useUpdateInstanceType>['mutate']>[0],
) => {
let captured: Record<string, unknown> | undefined;
const transport = createMockConnectTransport(
{ privateInstanceTypes: [makeInstanceType(input.id)] },
{
onInstanceTypeUpdate: (req) => {
captured = req as unknown as Record<string, unknown>;
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 } };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<typeof invalidateInstanceTypesQueries>[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);
});
});
43 changes: 41 additions & 2 deletions libs/ui-components/src/api/v1/private/instance-type.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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<typeof InstanceTypeSchema>;
};

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<string, unknown>) },
});
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),
});
};
Original file line number Diff line number Diff line change
@@ -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<typeof renderWithProviders>[1],
) => renderWithProviders(<AdminInstanceTypeActionsMenu instanceType={instanceType} />, options);

const openMenu = async (user: ReturnType<typeof renderWithProviders>['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<string, unknown> | undefined;
const { user } = renderMenu(makeInstanceType(InstanceTypeState.ACTIVE), {
transportOverrides: {
onInstanceTypeUpdate: (req) => {
captured = req as unknown as Record<string, unknown>;
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();
});
});
Loading
Loading