From 30c2181354022705c5cbf8c5e91fc56f0cb8c68f Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:54:15 -0400 Subject: [PATCH 01/41] refactor: [UIE-9323] - Replace Formik with React Hook Form in DatabaseManageNetworkingDrawer (#13002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description ๐Ÿ“ Refactor Database Manage Networking drawer to use React Hook Form instead of Formik ## How to test ๐Ÿงช ### Prerequisites (How to setup test environment) - Have the Database VPC feature flag enabled ### Verification steps (How to verify changes) - [ ] Go to the details page of a Database and click on the Networking tab, then click on Manage Networking to open the drawer - [ ] Test adding/removing a VPC, error messages, etc - [ ] There should be no regressions compared to prod --- .../DatabaseCreate/DatabaseCreate.tsx | 2 +- .../DatabaseCreateNetworkingConfiguration.tsx | 4 +- .../DatabaseCreate/DatabaseCreateVPC.tsx | 43 +++ ...Selector.test.tsx => DatabaseVPC.test.tsx} | 355 +++++++----------- ...atabaseVPCSelector.tsx => DatabaseVPC.tsx} | 67 ++-- .../DatabaseNetworking/DatabaseDetailVPC.tsx | 34 ++ .../DatabaseManageNetworkingDrawer.tsx | 173 ++++----- .../DatabaseVPCSelector.tsx | 243 ------------ 8 files changed, 333 insertions(+), 588 deletions(-) create mode 100644 packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateVPC.tsx rename packages/manager/src/features/Databases/DatabaseCreate/{DatabaseVPCSelector.test.tsx => DatabaseVPC.test.tsx} (57%) rename packages/manager/src/features/Databases/DatabaseCreate/{DatabaseVPCSelector.tsx => DatabaseVPC.tsx} (78%) create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC.tsx delete mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index d9b3b0d8aa2..7c670ebf370 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -53,7 +53,7 @@ export interface DatabaseCreateValues { cluster_size: ClusterSize; engine: Engine; label: string; - private_network?: PrivateNetwork; + private_network?: null | PrivateNetwork; region: string; type: string; } diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx index 30d03f0a799..46353595165 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx @@ -2,7 +2,7 @@ import { Typography } from '@linode/ui'; import * as React from 'react'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; -import { DatabaseVPCSelector } from './DatabaseVPCSelector'; +import { DatabaseCreateVPC } from './DatabaseCreateVPC'; import type { AccessProps } from './DatabaseCreateAccessControls'; import type { VPC } from '@linode/api-v4'; @@ -30,7 +30,7 @@ export const DatabaseCreateNetworkingConfiguration = ( - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateVPC.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateVPC.tsx new file mode 100644 index 00000000000..6143a0c9037 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateVPC.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { DatabaseVPC } from './DatabaseVPC'; + +import type { DatabaseCreateValues } from './DatabaseCreate'; +import type { VPC } from '@linode/api-v4'; + +interface DatabaseCreateVPCProps { + onChange: (selectedVPC: null | VPC) => void; +} + +export const DatabaseCreateVPC = (props: DatabaseCreateVPCProps) => { + const { onChange } = props; + + const { control } = + useFormContext>(); + + const { control: networkControl, setValue } = + useFormContext>(); + + const region = useWatch({ + control, + name: 'region', + }); + + const [vpcId, subnetId] = useWatch({ + control: networkControl, + name: ['private_network.vpc_id', 'private_network.subnet_id'], + }); + + return ( + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.test.tsx similarity index 57% rename from packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx rename to packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.test.tsx index 1ca6bade784..a0b01817574 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.test.tsx @@ -5,9 +5,10 @@ import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { subnetFactory, vpcFactory } from 'src/factories'; -import { DatabaseVPCSelector } from 'src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { DatabaseCreateVPC } from 'src/features/Databases/DatabaseCreate/DatabaseCreateVPC'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; +import type { DatabaseCreateValues } from './DatabaseCreate'; import type { PrivateNetwork } from '@linode/api-v4'; // Hoist query mocks @@ -62,21 +63,12 @@ const vpcSelectorTestId = 'database-vpc-selector'; const subnetSelectorTestId = 'database-subnet-selector'; const vpcPlaceholder = 'Select a VPC'; const subnetPlaceholder = 'Select a subnet'; -const mockMode: 'create' | 'networking' = 'create'; - -describe('DatabaseVPCSelector', () => { - const mockProps = { - errors: {}, - onChange: vi.fn(), - onConfigurationChange: vi.fn(), - privateNetworkValues: { - vpc_id: null, - subnet_id: null, - public_access: false, - }, - resetFormFields: vi.fn(), - selectedRegionId: '', - mode: mockMode, + +describe('DatabaseCreateVPC', () => { + const defaultPrivateNetworkValues = { + vpc_id: null, + subnet_id: null, + public_access: false, }; beforeEach(() => { @@ -92,13 +84,23 @@ describe('DatabaseVPCSelector', () => { }); it('Should render the VPC selector heading', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { private_network: defaultPrivateNetworkValues }, + }, + }); const vpcField = screen.getByText('Assign a VPC', { exact: true }); expect(vpcField).toBeInTheDocument(); }); it('Should render VPC autocomplete in initial disabled state', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { private_network: defaultPrivateNetworkValues }, + }, + }); const vpcSelector = screen.getByTestId(vpcSelectorTestId); expect(vpcSelector).toBeInTheDocument(); const vpcSelectorInput = screen.getByPlaceholderText(vpcPlaceholder); @@ -126,8 +128,15 @@ describe('DatabaseVPCSelector', () => { isLoading: false, }); - const mockEnabledProps = { ...mockProps, selectedRegionId: 'us-east' }; - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); const vpcSelector = screen.getByTestId(vpcSelectorTestId); expect(vpcSelector).toBeInTheDocument(); @@ -151,12 +160,15 @@ describe('DatabaseVPCSelector', () => { public_access: false, }; - const mockEnabledProps = { - ...mockProps, - privateNetworkValues: mockPrivateNetwork, - selectedRegionId: 'us-east', - }; - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); const vpcInput = screen.getByPlaceholderText( vpcPlaceholder @@ -184,12 +196,15 @@ describe('DatabaseVPCSelector', () => { public_access: true, }; - const mockEnabledProps = { - ...mockProps, - privateNetworkValues: mockPrivateNetwork, - selectedRegionId: 'us-east', - }; - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); const vpcInput = screen.getByPlaceholderText( vpcPlaceholder @@ -243,29 +258,29 @@ describe('DatabaseVPCSelector', () => { const resetFormFields = vi.fn(); const onConfigurationChange = vi.fn(); - const { rerender } = renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); // Change region to a new one queryMocks.useRegionQuery.mockReturnValue({ data: region2 }); queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); - rerender( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-west', + }, + }, + }); expect(resetFormFields).toHaveBeenCalled(); expect(onConfigurationChange).toHaveBeenCalledWith(null); @@ -283,34 +298,44 @@ describe('DatabaseVPCSelector', () => { const resetFormFields = vi.fn(); const onConfigurationChange = vi.fn(); - const { rerender } = renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: '', + }, + }, + }); // Now render with a valid region queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); - rerender( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); expect(resetFormFields).not.toHaveBeenCalled(); expect(onConfigurationChange).not.toHaveBeenCalledWith(null); }); it('Should show long helper text when no region is selected', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: '', + }, + }, + }); const expectedHelperText = screen.getByText(initialHelperText, { exact: true, }); @@ -321,9 +346,15 @@ describe('DatabaseVPCSelector', () => { queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); queryMocks.useAllVPCsQuery.mockReturnValue({ data: [], isLoading: false }); - renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); const expectedHelperText = screen.getByText(altHelperText, { exact: true }); expect(expectedHelperText).toBeInTheDocument(); @@ -340,77 +371,24 @@ describe('DatabaseVPCSelector', () => { isLoading: false, }); - renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: defaultPrivateNetworkValues, + region: 'us-east', + }, + }, + }); + const expectedAltHelperText = screen.queryByText(altHelperText); const expectedInitialHelperText = screen.queryByText(initialHelperText); expect(expectedAltHelperText).not.toBeInTheDocument(); expect(expectedInitialHelperText).not.toBeInTheDocument(); }); - it('Should show vpc validation error text when there is a vpc error', () => { - setUpBaseMocks(); - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: 1234, - subnet_id: null, - public_access: false, - }; - - const mockErrors = { - private_network: { - vpc_id: 'VPC is required.', - }, - }; - - renderWithTheme( - - ); - - const subnetSelector = screen.getByTestId(subnetSelectorTestId); - expect(subnetSelector).toBeInTheDocument(); - const expectedValidationError = screen.getByText('VPC is required.'); - expect(expectedValidationError).toBeInTheDocument(); - }); - - it('Should show subnet validation error text when there is a subnet error', () => { - setUpBaseMocks(); - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: 1234, - subnet_id: null, - public_access: false, - }; - - const mockErrors = { - private_network: { - subnet_id: 'Subnet is required.', - }, - }; - - renderWithTheme( - - ); - - const subnetSelector = screen.getByTestId(subnetSelectorTestId); - expect(subnetSelector).toBeInTheDocument(); - const expectedValidationError = screen.getByText('Subnet is required.'); - expect(expectedValidationError).toBeInTheDocument(); - }); - - it('Should clear subnet field when the VPC field is cleared', async () => { + it('Should hide the subnet field when the VPC field is cleared', async () => { setUpBaseMocks(); - const onChange = vi.fn(); - // Start with both VPC and subnet selected const mockPrivateNetwork: PrivateNetwork = { vpc_id: 1234, @@ -418,102 +396,29 @@ describe('DatabaseVPCSelector', () => { public_access: false, }; - renderWithTheme( - - ); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + private_network: mockPrivateNetwork, + region: 'us-east', + }, + }, + }); - // Simulate clearing the VPC field (user clears the Autocomplete) + // Clear VPC selection const vpcSelector = screen.getByTestId(vpcSelectorTestId); const clearButton = vpcSelector.querySelector( 'button[title="Clear"]' ) as HTMLElement; await userEvent.click(clearButton); - // ...assertions as above... - expect(onChange).toHaveBeenCalledWith('private_network.vpc_id', null); - expect(onChange).toHaveBeenCalledWith('private_network.subnet_id', null); - expect(onChange).toHaveBeenCalledWith( - 'private_network.public_access', - false - ); - }); - - it('Should call onChange for the VPC field when a value is selected', async () => { - setUpBaseMocks(); - const onChange = vi.fn(); - - // Start with no VPC selected - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: null, - subnet_id: null, - public_access: false, - }; - renderWithTheme( - - ); - - // Simulate selecting a VPC from the Autocomplete - const vpcInput = screen.getByPlaceholderText( - vpcPlaceholder - ) as HTMLInputElement; - // Open the autocomplete dropdown - await userEvent.click(vpcInput); - - // Select the option - const newVPC = await screen.findByText('VPC 1'); - await userEvent.click(newVPC); - - expect(onChange).toHaveBeenCalledWith( - 'private_network.vpc_id', - mockVPCWithSubnet.id - ); - }); - - it('Should call onChange for the Subnet field when subnet value is selected', async () => { - setUpBaseMocks(); - const onChange = vi.fn(); - - // Start with VPC selected and no subnet selection - const mockPrivateNetwork: PrivateNetwork = { - vpc_id: 1234, - subnet_id: null, - public_access: false, - }; - - renderWithTheme( - - ); - - // Simulate selecting a Subnet from the Autocomplete - const subnetInput = screen.getByPlaceholderText( - subnetPlaceholder - ) as HTMLInputElement; - - await userEvent.click(subnetInput); - - // Select the option - const expectedSubnetLabel = `${mockSubnets[0].label} (${mockSubnets[0].ipv4})`; - const newSubnet = await screen.findByText(expectedSubnetLabel); - await userEvent.click(newSubnet); - - expect(onChange).toHaveBeenCalledWith( - 'private_network.subnet_id', - mockSubnets[0].id - ); + const subnetSelector = screen.queryByTestId(subnetSelectorTestId); + expect(subnetSelector).not.toBeInTheDocument(); + expect( + screen.getByText( + 'The cluster will have public access by default if a VPC is not assigned.' + ) + ).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.tsx similarity index 78% rename from packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx rename to packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.tsx index 843d55aefdf..c7d0a1bfb5f 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPC.tsx @@ -9,31 +9,44 @@ import { Typography, } from '@linode/ui'; import * as React from 'react'; -import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import type { Control, UseFormSetValue, UseFormTrigger } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; import { Link } from 'src/components/Link'; +import { MANAGE_NETWORKING_LEARN_MORE_LINK } from 'src/features/Databases/constants'; import { useFlags } from 'src/hooks/useFlags'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../constants'; - -import type { DatabaseCreateValues } from './DatabaseCreate'; -import type { VPC } from '@linode/api-v4'; +import type { PrivateNetwork, VPC } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -interface DatabaseVPCSelectorProps { - onChange: (selectedVPC: null | VPC) => void; +interface NetworkValues { + private_network?: null | PrivateNetwork; } -export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { - const { onChange } = props; - const flags = useFlags(); - const { control, setValue } = useFormContext(); +interface DatabaseVPCProps { + control: Control; + mode: 'create' | 'networking'; + onChange?: (selectedVPC: null | VPC) => void; + region: string; + setValue: UseFormSetValue; + subnetId: null | number; + trigger?: UseFormTrigger; + vpcId: null | number; +} - const [region, vpcId, subnetId] = useWatch({ +export const DatabaseVPC = (props: DatabaseVPCProps) => { + const { + onChange, + setValue, + trigger, control, - name: ['region', 'private_network.vpc_id', 'private_network.subnet_id'], - }); + region, + vpcId, + subnetId, + mode, + } = props; + const flags = useFlags(); const { data: selectedRegion } = useRegionQuery(region); const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); @@ -41,7 +54,7 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { const { data: vpcs, error: vpcsError, - isLoading, + isLoading: vpcsLoading, } = useAllVPCsQuery({ enabled: regionSupportsVPCs, filter: { region }, @@ -96,19 +109,20 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { errorText={vpcErrorMessage || fieldState.error?.message} helperText={disableVPCSelectors ? vpcHelperTextCopy : undefined} label="VPC" - loading={isLoading} + loading={vpcsLoading} noOptionsText="There are no VPCs in the selected region." onChange={(e, value) => { setValue('private_network.subnet_id', null); // Always reset subnet selection when VPC changes + trigger?.('private_network.subnet_id'); if (!value) { setValue('private_network.public_access', false); } - onChange(value ?? null); // Update VPC in DatabaseCreate.tsx + onChange?.(value ?? null); // Update VPC in DatabaseCreate.tsx field.onChange(value?.id ?? null); }} options={vpcs ?? []} placeholder="Select a VPC" - sx={{ width: '354px' }} + sx={{ width: '390px' }} textFieldProps={{ tooltipText: 'A cluster may be assigned only to a VPC in the same region', @@ -132,6 +146,7 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { label="Subnet" onChange={(e, value) => { field.onChange(value?.id ?? null); + trigger?.('private_network.subnet_id'); }} options={selectedVPC?.subnets ?? []} placeholder="Select a subnet" @@ -176,13 +191,15 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { ) : ( - ({ - marginTop: theme.spacingFunction(20), - })} - text="The cluster will have public access by default if a VPC is not assigned." - variant="info" - /> + mode === 'create' && ( + ({ + marginTop: theme.spacingFunction(20), + })} + text="The cluster will have public access by default if a VPC is not assigned." + variant="info" + /> + ) )} ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC.tsx new file mode 100644 index 00000000000..7ddaa0a9145 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { DatabaseVPC } from '../../DatabaseCreate/DatabaseVPC'; + +import type { ManageNetworkingFormValues } from './DatabaseManageNetworkingDrawer'; + +interface DatabaseDetailVPCProps { + region: string; +} + +export const DatabaseDetailVPC = (props: DatabaseDetailVPCProps) => { + const { region } = props; + + const { control, setValue, trigger } = + useFormContext(); + + const [vpcId, subnetId] = useWatch({ + control, + name: ['private_network.vpc_id', 'private_network.subnet_id'], + }); + + return ( + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx index bd5c235a3fa..c3d9bca2806 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx @@ -1,19 +1,15 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useDatabaseMutation } from '@linode/queries'; import { Box, Button, Drawer, Notice } from '@linode/ui'; import { updatePrivateNetworkSchema } from '@linode/validation'; import { useNavigate } from '@tanstack/react-router'; -import { useFormik } from 'formik'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; -import { DatabaseVPCSelector } from './DatabaseVPCSelector'; +import { DatabaseDetailVPC } from 'src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseDetailVPC'; -import type { - Database, - PrivateNetwork, - UpdateDatabasePayload, - VPC, -} from '@linode/api-v4'; +import type { Database, UpdateDatabasePayload, VPC } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; interface Props { @@ -24,9 +20,10 @@ interface Props { vpc: undefined | VPC; } -export type ManageNetworkingFormValues = { - private_network: PrivateNetwork; -}; +export type ManageNetworkingFormValues = Pick< + UpdateDatabasePayload, + 'private_network' +>; const DatabaseManageNetworkingDrawer = (props: Props) => { const { database, vpc, onClose, onUnassign, open } = props; @@ -41,10 +38,22 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { }, }; - const submitForm = () => { - const payload: UpdateDatabasePayload = { ...values }; + const form = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + // @ts-expect-error handle null validation with trigger + resolver: yupResolver(updatePrivateNetworkSchema), + }); + + const { + formState: { isDirty, isValid }, + handleSubmit, + reset, + watch, + } = form; - updateDatabase(payload).then(() => { + const onSubmit = (values: ManageNetworkingFormValues) => { + updateDatabase(values).then(() => { enqueueSnackbar('Changes are being applied.', { variant: 'info', }); @@ -59,34 +68,20 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { }); }; - const { - errors, - handleSubmit, - resetForm, - isValid, - dirty, - setFieldValue, - values, - } = useFormik({ - initialValues, - onSubmit: submitForm, - validationSchema: updatePrivateNetworkSchema, - validateOnChange: true, - validateOnBlur: true, - }); // TODO (UIE-8903): Replace deprecated Formik with React Hook Form + const [publicAccess, subnetId, vpcId] = watch([ + 'private_network.public_access', + 'private_network.subnet_id', + 'private_network.vpc_id', + ]); const hasVPCConfigured = !!database?.private_network?.vpc_id; const hasConfigChanged = - values.private_network.vpc_id !== database?.private_network?.vpc_id || - values.private_network.subnet_id !== database?.private_network?.subnet_id || - values.private_network.public_access !== - database?.private_network?.public_access; - const hasValidSelection = - !!values.private_network.vpc_id && - !!values.private_network.subnet_id && - hasConfigChanged; + vpcId !== database?.private_network?.vpc_id || + subnetId !== database?.private_network?.subnet_id || + publicAccess !== database?.private_network?.public_access; + const hasValidSelection = !!vpcId && !!subnetId && hasConfigChanged; - const isSaveDisabled = !dirty || !isValid || !hasValidSelection; + const isSaveDisabled = !isDirty || !isValid || !hasValidSelection; const { error: manageNetworkingError, @@ -97,14 +92,14 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { const handleOnClose = () => { onClose(); - resetForm(); + reset(); resetMutation?.(); }; /** Resets the form after opening the unassign VPC dialog */ const handleOnUnassign = () => { onUnassign(); - resetForm(); + reset(); resetMutation?.(); }; @@ -113,59 +108,53 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { {manageNetworkingError && ( )} -
- - setFieldValue(field, value) - } - privateNetworkValues={values.private_network} - selectedRegionId={database?.region} - /> - ({ - marginTop: theme.spacingFunction(50), - paddingTop: theme.spacingFunction(8), - paddingBottom: theme.spacingFunction(8), - display: 'flex', - justifyContent: hasVPCConfigured ? 'space-between' : 'flex-end', - })} - > - {hasVPCConfigured && ( - - )} - - - + + + + ({ + marginTop: theme.spacingFunction(50), + paddingTop: theme.spacingFunction(8), + paddingBottom: theme.spacingFunction(8), + display: 'flex', + justifyContent: hasVPCConfigured ? 'space-between' : 'flex-end', + })} + > + {hasVPCConfigured && ( + + )} + + + + - - + + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx deleted file mode 100644 index 6f5c7b7b220..00000000000 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useAllVPCsQuery, useRegionQuery } from '@linode/queries'; -import { - Autocomplete, - BetaChip, - Box, - Checkbox, - FormHelperText, - Notice, - TooltipIcon, - Typography, -} from '@linode/ui'; -import * as React from 'react'; - -import { Link } from 'src/components/Link'; -import { MANAGE_NETWORKING_LEARN_MORE_LINK } from 'src/features/Databases/constants'; -import { useFlags } from 'src/hooks/useFlags'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; - -import type { ClusterSize, Engine, PrivateNetwork, VPC } from '@linode/api-v4'; -import type { Theme } from '@mui/material/styles'; -import type { FormikErrors } from 'formik'; - -interface DatabaseCreateValuesFormik { - allow_list: { - address: string; - error: string; - }[]; - cluster_size: ClusterSize; - engine: Engine; - label: string; - private_network: PrivateNetwork; - region: string; - type: string; -} - -interface DatabaseVPCSelectorProps { - errors: FormikErrors; // TODO (UIE-8903): Replace deprecated Formik with React Hook Form - mode: 'create' | 'networking'; - onChange: (field: string, value: boolean | null | number) => void; - onConfigurationChange?: (vpc: null | VPC) => void; - privateNetworkValues: PrivateNetwork; - resetFormFields?: ( - partialValues?: Partial - ) => void; - selectedRegionId: string; -} - -export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { - const { - errors, - mode, - onConfigurationChange, - onChange, - selectedRegionId, - resetFormFields, - privateNetworkValues, - } = props; - - const flags = useFlags(); - const isCreate = mode === 'create'; - const { data: selectedRegion } = useRegionQuery(selectedRegionId); - const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); - - const { - data: vpcs, - error: vpcsError, - isLoading, - } = useAllVPCsQuery({ - enabled: regionSupportsVPCs, - filter: { region: selectedRegionId }, - }); - - const vpcErrorMessage = - vpcsError && - getAPIErrorOrDefault(vpcsError, 'Unable to load VPCs')[0].reason; - - const selectedVPC = vpcs?.find( - (vpc) => vpc.id === privateNetworkValues.vpc_id - ); - - const selectedSubnet = selectedVPC?.subnets.find( - (subnet) => subnet.id === privateNetworkValues.subnet_id - ); - - const prevRegionId = React.useRef(undefined); - const regionHasVPCs = Boolean(vpcs && vpcs.length > 0); - const disableVPCSelectors = - !!vpcsError || !regionSupportsVPCs || !regionHasVPCs; - - const resetVPCConfiguration = () => { - resetFormFields?.({ - private_network: { - vpc_id: null, - subnet_id: null, - public_access: false, - }, - }); - }; - - React.useEffect(() => { - // When the selected region has changed, reset VPC configuration. - // Then switch back to default validation behavior - if (prevRegionId.current && prevRegionId.current !== selectedRegionId) { - resetVPCConfiguration(); - onConfigurationChange?.(null); - } - prevRegionId.current = selectedRegionId; - }, [selectedRegionId]); - - const vpcHelperTextCopy = !selectedRegionId - ? 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.' - : 'No VPC is available in the selected region.'; - - /** Returns dynamic marginTop value used to center TooltipIcon in different scenarios */ - const getVPCTooltipIconMargin = () => { - const margins = { - longHelperText: '.75rem', - shortHelperText: '1.75rem', - noHelperText: '2.75rem', - errorText: '1.5rem', - errorTextWithLongHelperText: '-.5rem', - }; - if (disableVPCSelectors && vpcsError) - return margins.errorTextWithLongHelperText; - if (errors?.private_network?.vpc_id) return margins.errorText; - if (disableVPCSelectors && !selectedRegionId) return margins.longHelperText; - if (disableVPCSelectors && selectedRegionId) return margins.shortHelperText; - return margins.noHelperText; - }; - - const accessNotice = isCreate && ( - ({ - marginTop: theme.spacingFunction(20), - })} - text="The cluster will have public access by default if a VPC is not assigned." - variant="info" - /> - ); - - return ( - <> - ({ - display: 'flex', - marginTop: theme.spacingFunction(20), - marginBottom: theme.spacingFunction(4), - })} - > - Assign a VPC - {flags.databaseVpcBeta && } - - - - Assign this cluster to an existing VPC.{' '} - - Learn more. - - - - { - onChange('private_network.subnet_id', null); // Always reset subnet selection when VPC changes - if (!value) { - onChange('private_network.public_access', false); - } - onConfigurationChange?.(value ?? null); - onChange('private_network.vpc_id', value?.id ?? null); - }} - options={vpcs ?? []} - placeholder="Select a VPC" - sx={{ width: '354px' }} - value={selectedVPC ?? null} - /> - - - - {selectedVPC ? ( - <> - `${subnet.label} (${subnet.ipv4})`} - label="Subnet" - onChange={(e, value) => { - onChange('private_network.subnet_id', value?.id ?? null); - }} - options={selectedVPC?.subnets ?? []} - placeholder="Select a subnet" - value={selectedSubnet ?? null} - /> - ({ - marginTop: theme.spacingFunction(20), - })} - > - { - onChange('private_network.public_access', value ?? null); - }} - text={'Enable public access'} - toolTipText={ - 'Adds a public endpoint to the database in addition to the private VPC endpoint.' - } - /> - {errors?.private_network?.public_access && ( - - {errors?.private_network?.public_access} - - )} - - - ) : ( - accessNotice - )} - - ); -}; From 7f451c908ead7c6364532731a33207155bab26c2 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Fri, 31 Oct 2025 08:35:17 +0100 Subject: [PATCH 02/41] upcoming: [DPS-34191] - logs delivery pendo tags (#13022) * upcoming: [DPS-34191] - logs delivery pendo tags * Added changeset: Logs Delivery Stream/Destination Pendo tags * Change Edit Destination/Stream button to Save button * update e2e with Edit to Save change --- ...r-13022-upcoming-features-1761581678060.md | 5 ++ .../core/delivery/edit-destination.spec.ts | 14 +++--- .../src/components/ActionMenu/ActionMenu.tsx | 17 ++++++- .../ResourcesSection.tsx | 1 + .../Destinations/DeleteDestinationDialog.tsx | 7 ++- .../Destinations/DestinationActionMenu.tsx | 6 ++- .../DestinationForm/DestinationEdit.test.tsx | 20 ++++---- .../DestinationForm/DestinationForm.tsx | 8 ++- .../Destinations/DestinationTableRow.tsx | 5 +- .../DestinationsLandingEmptyState.tsx | 2 + .../DeliveryTabHeader/DeliveryTabHeader.tsx | 30 ++++++++++-- ...tinationAkamaiObjectStorageDetailsForm.tsx | 17 ++++++- .../FormSubmitBar/FormSubmitBar.test.tsx | 4 +- .../Shared/FormSubmitBar/FormSubmitBar.tsx | 14 +++++- .../src/features/Delivery/Shared/types.ts | 11 +++-- .../Delivery/Streams/DeleteStreamDialog.tsx | 7 ++- .../Delivery/Streams/StreamActionMenu.tsx | 8 ++- .../Clusters/StreamFormClusters.test.tsx | 4 +- .../Clusters/StreamFormClusters.tsx | 11 ++++- .../Delivery/StreamFormDelivery.test.tsx | 3 ++ .../Delivery/StreamFormDelivery.tsx | 15 +++++- .../Streams/StreamForm/StreamEdit.test.tsx | 22 ++++----- .../Streams/StreamForm/StreamForm.tsx | 3 +- .../StreamForm/StreamFormGeneralInfo.tsx | 49 +++++++++++++++++-- .../Delivery/Streams/StreamTableRow.tsx | 7 ++- .../Streams/StreamsLandingEmptyState.tsx | 1 + .../src/features/Delivery/deliveryUtils.ts | 6 +-- .../components/ActionsPanel/ActionsPanel.tsx | 1 + 28 files changed, 236 insertions(+), 62 deletions(-) create mode 100644 packages/manager/.changeset/pr-13022-upcoming-features-1761581678060.md diff --git a/packages/manager/.changeset/pr-13022-upcoming-features-1761581678060.md b/packages/manager/.changeset/pr-13022-upcoming-features-1761581678060.md new file mode 100644 index 00000000000..fad1b6953d3 --- /dev/null +++ b/packages/manager/.changeset/pr-13022-upcoming-features-1761581678060.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Logs Delivery Stream/Destination Pendo tags ([#13022](https://github.com/linode/manager/pull/13022)) diff --git a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts index ffce8d67d0c..fbbd0a99328 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts @@ -43,8 +43,8 @@ describe('Edit Destination', () => { mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended ); - // Create Destination should be disabled before test connection - cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Save button should be disabled before test connection + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); // Test connection of the destination form mockTestConnection(400); ui.button @@ -57,8 +57,8 @@ describe('Edit Destination', () => { 'Delivery connection test failed. Verify your delivery settings and try again.' ); - // Create Destination should be disabled after test connection failed - cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Save button should be disabled after test connection failed + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); }); it('edit destination with correct data', () => { @@ -70,8 +70,8 @@ describe('Edit Destination', () => { mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended ); - // Create Destination should be disabled before test connection - cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Save button should be disabled before test connection + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); // Test connection of the destination form mockTestConnection(); ui.button @@ -88,7 +88,7 @@ describe('Edit Destination', () => { mockUpdateDestination(mockDestinationPayloadWithId, updatedDestination); mockGetDestinations([updatedDestination]); // Submit the destination edit form - cy.findByRole('button', { name: 'Edit Destination' }) + cy.findByRole('button', { name: 'Save' }) .should('be.enabled') .should('have.attr', 'type', 'button') .click(); diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index b752d7794d3..baae0362351 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -11,6 +11,7 @@ export interface Action { hidden?: boolean; id?: string; onClick: () => void; + pendoId?: string; title: string; tooltip?: string; } @@ -32,6 +33,10 @@ export interface ActionMenuProps { * A function that is called when the Menu is opened. Useful for analytics. */ onOpen?: () => void; + /** + * Pendo ID to be added to ActionMenu IconButton via data-pendo-id attribute + */ + pendoId?: string; /** * If true, stop event propagation when handling clicks * Ex: If the action menu is in an accordion, we don't want the click also opening/closing the accordion @@ -45,8 +50,14 @@ export interface ActionMenuProps { * No more than 8 items should be displayed within an action menu. */ export const ActionMenu = React.memo((props: ActionMenuProps) => { - const { actionsList, ariaLabel, loading, onOpen, stopClickPropagation } = - props; + const { + actionsList, + ariaLabel, + loading, + onOpen, + pendoId, + stopClickPropagation, + } = props; const filteredActionsList = actionsList.filter((action) => !action.hidden); @@ -102,6 +113,7 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { aria-haspopup="true" aria-label={ariaLabel} color="inherit" + data-pendo-id={pendoId} id={buttonId} loading={loading} loadingIndicator={} @@ -159,6 +171,7 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { > {filteredActionsList.map((a, idx) => ( void; tooltipText?: string; diff --git a/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx b/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx index ee2a8391531..4dcd636348a 100644 --- a/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx @@ -55,8 +55,13 @@ export const DeleteDestinationDialog = React.memo((props: Props) => { loading: isPending, disabled: false, onClick: handleDelete, + 'data-pendo-id': 'Logs Delivery Destinations Delete-Delete', + }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: onClose, + 'data-pendo-id': 'Logs Delivery Destinations Delete-Cancel', }} - secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} style={{ padding: 0 }} /> ); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx index b3d1bfea622..5da8931abf6 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import type { Destination } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface DestinationHandlers { onDelete: (destination: Destination) => void; @@ -16,18 +17,20 @@ interface DestinationActionMenuProps extends DestinationHandlers { export const DestinationActionMenu = (props: DestinationActionMenuProps) => { const { destination, onDelete, onEdit } = props; - const menuActions = [ + const menuActions: Action[] = [ { onClick: () => { onEdit(destination); }, title: 'Edit', + pendoId: 'Logs Delivery Destinations-Edit', }, { onClick: () => { onDelete(destination); }, title: 'Delete', + pendoId: 'Logs Delivery Destinations-Delete', }, ]; @@ -35,6 +38,7 @@ export const DestinationActionMenu = (props: DestinationActionMenuProps) => { ); }; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx index 50f850619ae..561272ddc52 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -60,7 +60,7 @@ describe('DestinationEdit', () => { describe('given Test Connection and Edit Destination buttons', () => { const testConnectionButtonText = 'Test Connection'; - const editDestinationButtonText = 'Edit Destination'; + const saveDestinationButtonText = 'Save'; const editDestinationSpy = vi.fn(); const verifyDestinationSpy = vi.fn(); @@ -89,23 +89,23 @@ describe('DestinationEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editDestinationButton = screen.getByRole('button', { - name: editDestinationButtonText, + const saveDestinationButton = screen.getByRole('button', { + name: saveDestinationButtonText, }); // Enter Secret Access Key const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); await userEvent.type(secretAccessKeyInput, 'Test'); - expect(editDestinationButton).toBeDisabled(); + expect(saveDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { - expect(editDestinationButton).toBeEnabled(); + expect(saveDestinationButton).toBeEnabled(); }); - await userEvent.click(editDestinationButton); + await userEvent.click(saveDestinationButton); expect(editDestinationSpy).toHaveBeenCalled(); }); }); @@ -131,20 +131,20 @@ describe('DestinationEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editDestinationButton = screen.getByRole('button', { - name: editDestinationButtonText, + const saveDestinationButton = screen.getByRole('button', { + name: saveDestinationButtonText, }); // Enter Secret Access Key const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); await userEvent.type(secretAccessKeyInput, 'Test'); - expect(editDestinationButton).toBeDisabled(); + expect(saveDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { - expect(editDestinationButton).toBeDisabled(); + expect(saveDestinationButton).toBeDisabled(); }); }); }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index d80ecc78a87..842ace856e9 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -1,5 +1,6 @@ import { destinationType } from '@linode/api-v4'; import { Autocomplete, Paper, TextField } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useEffect } from 'react'; @@ -53,6 +54,7 @@ export const DestinationForm = (props: DestinationFormProps) => { name="type" render={({ field }) => ( { render={({ field, fieldState }) => ( { )} /> {destination.type === destinationType.AkamaiObjectStorage && ( - + )} diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx index 0fed3b16756..8ef54400dda 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx @@ -23,7 +23,10 @@ export const DestinationTableRow = React.memo( return ( - + {destination.label} diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx index 23226180731..b3ffe5d4ef0 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyState.tsx @@ -34,6 +34,8 @@ export const DestinationsLandingEmptyState = ( }); navigateToCreate(); }, + 'data-pendo-id': + 'Logs Delivery Destinations Empty-Create Destination', }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx index c2155a8cb1b..da750811f6b 100644 --- a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx +++ b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, Button } from '@linode/ui'; +import { Autocomplete, Box, Button, SelectedIcon } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -7,19 +7,19 @@ import * as React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import type { Theme } from '@mui/material/styles'; -import type { LabelValueOption } from 'src/features/Delivery/Shared/types'; +import type { AutocompleteOption } from 'src/features/Delivery/Shared/types'; export interface DeliveryTabHeaderProps { buttonDataAttrs?: { [key: string]: boolean | string }; createButtonText?: string; disabledCreateButton?: boolean; - entity?: string; + entity: string; isSearching?: boolean; onButtonClick?: () => void; onSearch?: (label: string) => void; onSelect?: (status: string) => void; searchValue?: string; - selectList?: LabelValueOption[]; + selectList?: AutocompleteOption[]; selectValue?: string; spacingBottom?: 0 | 4 | 16 | 24; } @@ -88,6 +88,7 @@ export const DeliveryTabHeader = ({ {onSearch && searchValue !== undefined && ( {selectList && onSelect && ( { @@ -106,12 +108,32 @@ export const DeliveryTabHeader = ({ }} options={selectList} placeholder="Select" + renderOption={(props, option, { selected }) => { + return ( +
  • + + {option.label} + + +
  • + ); + }} value={selectList.find(({ value }) => value === selectValue)} /> )} {onButtonClick && ( diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index 30067fbf20d..8c447865f22 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -9,12 +9,13 @@ import type { export type FormMode = 'create' | 'edit'; export type FormType = 'destination' | 'stream'; -export interface LabelValueOption { +export interface AutocompleteOption { label: string; + pendoId?: string; value: string; } -export const destinationTypeOptions: LabelValueOption[] = [ +export const destinationTypeOptions: AutocompleteOption[] = [ { value: destinationType.CustomHttps, label: 'Custom HTTPS', @@ -25,7 +26,7 @@ export const destinationTypeOptions: LabelValueOption[] = [ }, ]; -export const streamTypeOptions: LabelValueOption[] = [ +export const streamTypeOptions: AutocompleteOption[] = [ { value: streamType.AuditLogs, label: 'Audit Logs', @@ -36,14 +37,16 @@ export const streamTypeOptions: LabelValueOption[] = [ }, ]; -export const streamStatusOptions: LabelValueOption[] = [ +export const streamStatusOptions: AutocompleteOption[] = [ { value: streamStatus.Active, label: 'Active', + pendoId: 'Logs Delivery Streams-Status Active', }, { value: streamStatus.Inactive, label: 'Inactive', + pendoId: 'Logs Delivery Streams-Status Inactive', }, ]; diff --git a/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx b/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx index 0bf28a7f0e6..e6fcc19809a 100644 --- a/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx +++ b/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx @@ -54,8 +54,13 @@ export const DeleteStreamDialog = React.memo((props: Props) => { loading: isPending, disabled: false, onClick: handleDelete, + 'data-pendo-id': 'Logs Delivery Streams Delete-Delete', + }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: onClose, + 'data-pendo-id': 'Logs Delivery Streams Delete-Cancel', }} - secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} style={{ padding: 0 }} /> ); diff --git a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx index 7484d82d32b..f976acd4096 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + export interface StreamHandlers { onDelete: (stream: Stream) => void; onDisableOrEnable: (stream: Stream) => void; @@ -16,24 +18,27 @@ interface StreamActionMenuProps extends StreamHandlers { export const StreamActionMenu = (props: StreamActionMenuProps) => { const { stream, onDelete, onDisableOrEnable, onEdit } = props; - const menuActions = [ + const menuActions: Action[] = [ { onClick: () => { onEdit(stream); }, title: 'Edit', + pendoId: 'Logs Delivery Streams-Edit', }, { onClick: () => { onDisableOrEnable(stream); }, title: stream.status === streamStatus.Active ? 'Deactivate' : 'Activate', + pendoId: `Logs Delivery Streams-${stream.status === streamStatus.Active ? 'Deactivate' : 'Activate'}`, }, { onClick: () => { onDelete(stream); }, title: 'Delete', + pendoId: 'Logs Delivery Streams-Delete', }, ]; @@ -41,6 +46,7 @@ export const StreamActionMenu = (props: StreamActionMenuProps) => { ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx index bc3d35d352e..315d63f3c7c 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx @@ -58,7 +58,7 @@ const renderComponentWithoutSelectedClusters = async () => { ); const utils = renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { @@ -181,7 +181,7 @@ describe('StreamFormClusters', () => { ); renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index 0a861adeb76..62d94f4bb9b 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -8,6 +8,7 @@ import { Paper, Typography, } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import React, { useEffect, useState } from 'react'; import { useWatch } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form'; @@ -19,6 +20,7 @@ import { Table } from 'src/components/Table'; import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; +import type { FormMode } from 'src/features/Delivery/Shared/types'; import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; @@ -28,7 +30,12 @@ const controlPaths = { clusterIds: 'stream.details.cluster_ids', } as const; -export const StreamFormClusters = () => { +interface StreamFormClustersProps { + mode: FormMode; +} + +export const StreamFormClusters = (props: StreamFormClustersProps) => { + const { mode } = props; const { control, setValue, formState, trigger } = useFormContext(); @@ -109,6 +116,7 @@ export const StreamFormClusters = () => { render={({ field }) => ( { field.onChange(checked); if (checked) { @@ -132,6 +140,7 @@ export const StreamFormClusters = () => { mt: 2, }, }} + data-pendo-id={`Logs Delivery Streams ${capitalize(mode)}-Clusters-Search`} debounceTime={250} errorText={searchParseError?.message} hideLabel diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index e9a6efec52e..13c18086d6e 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -30,6 +30,7 @@ describe('StreamFormDelivery', () => { renderWithThemeAndHookFormContext({ component: ( ), @@ -57,6 +58,7 @@ describe('StreamFormDelivery', () => { renderWithThemeAndHookFormContext({ component: ( ), @@ -91,6 +93,7 @@ describe('StreamFormDelivery', () => { renderWithThemeAndHookFormContext({ component: ( ), diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index db40ce4120d..bfe2a3d5809 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -8,6 +8,7 @@ import { Paper, Typography, } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { createFilterOptions } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import React, { useEffect, useState } from 'react'; @@ -22,12 +23,14 @@ import type { AkamaiObjectStorageDetails, DestinationType, } from '@linode/api-v4'; +import type { FormMode } from 'src/features/Delivery/Shared/types'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; interface DestinationName { create?: boolean; id?: number; label: string; + pendoId?: string; type?: DestinationType; } @@ -40,17 +43,20 @@ const controlPaths = { } as const; interface StreamFormDeliveryProps { + mode: FormMode; setDisableTestConnection: (disable: boolean) => void; } export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { - const { setDisableTestConnection } = props; + const { mode, setDisableTestConnection } = props; const theme = useTheme(); const { control, setValue, clearErrors } = useFormContext(); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); + const capitalizedMode = capitalize(mode); + const [creatingNewDestination, setCreatingNewDestination] = useState(false); @@ -96,6 +102,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { name="destination.type" render={({ field, fieldState }) => ( { name="destination.label" render={({ field, fieldState }) => ( { const filtered = destinationNameFilterOptions(options, params); @@ -127,6 +135,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { create: true, label: inputValue, type: selectedDestinationType, + pendoId: `Logs Delivery Streams ${capitalizedMode}-Destination Name-New`, }); } @@ -163,7 +172,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { renderOption={(props, option) => { const { id, ...optionProps } = props; return ( -
  • +
  • {option.create ? ( <> Create  "{option.label}" @@ -183,6 +192,8 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { {creatingNewDestination && !selectedDestinations?.length && ( )} {selectedDestinations?.[0] && ( diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx index c8b2edb073c..580b5590109 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -83,7 +83,7 @@ describe('StreamEdit', () => { { timeout: 10000 }, () => { const testConnectionButtonText = 'Test Connection'; - const editStreamButtonText = 'Edit Stream'; + const saveStreamButtonText = 'Save'; const fillOutNewDestinationForm = async () => { const destinationNameInput = screen.getByLabelText('Destination Name'); @@ -147,21 +147,21 @@ describe('StreamEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editStreamButton = screen.getByRole('button', { - name: editStreamButtonText, + const saveStreamButton = screen.getByRole('button', { + name: saveStreamButtonText, }); - expect(editStreamButton).toBeDisabled(); + expect(saveStreamButton).toBeDisabled(); // Test connection await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); await waitFor(() => { - expect(editStreamButton).toBeEnabled(); + expect(saveStreamButton).toBeEnabled(); }); // Edit stream - await userEvent.click(editStreamButton); + await userEvent.click(saveStreamButton); expect(createDestinationSpy).toHaveBeenCalled(); await waitFor(() => { @@ -206,7 +206,7 @@ describe('StreamEdit', () => { name: testConnectionButtonText, }); const editStreamButton = screen.getByRole('button', { - name: editStreamButtonText, + name: saveStreamButtonText, }); // Edit stream button should not be disabled with existing destination selected @@ -253,18 +253,18 @@ describe('StreamEdit', () => { const testConnectionButton = screen.getByRole('button', { name: testConnectionButtonText, }); - const editStreamButton = screen.getByRole('button', { - name: editStreamButtonText, + const saveStreamButton = screen.getByRole('button', { + name: saveStreamButtonText, }); await fillOutNewDestinationForm(); - expect(editStreamButton).toBeDisabled(); + expect(saveStreamButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); - expect(editStreamButton).toBeDisabled(); + expect(saveStreamButton).toBeDisabled(); }); }); } diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index 8baaff88cd9..62ebd97048c 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -180,9 +180,10 @@ export const StreamForm = (props: StreamFormProps) => { {selectedStreamType === streamType.LKEAuditLogs && ( - + )} diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index d48a955c83e..f7ea5f34f58 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -1,5 +1,13 @@ import { streamType } from '@linode/api-v4'; -import { Autocomplete, Paper, TextField, Typography } from '@linode/ui'; +import { + Autocomplete, + Box, + Paper, + SelectedIcon, + TextField, + Typography, +} from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -11,7 +19,11 @@ import { import { streamTypeOptions } from 'src/features/Delivery/Shared/types'; import type { StreamAndDestinationFormType } from './types'; -import type { FormMode } from 'src/features/Delivery/Shared/types'; +import type { StreamType } from '@linode/api-v4'; +import type { + AutocompleteOption, + FormMode, +} from 'src/features/Delivery/Shared/types'; interface StreamFormGeneralInfoProps { mode: FormMode; @@ -24,12 +36,22 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const theme = useTheme(); const { control, setValue } = useFormContext(); + const capitalizedMode = capitalize(mode); const description = { audit_logs: 'Configuration and authentication audit logs that capture state-changing operations (mutations) on Linode cloud infrastructure resources and IAM authentication events. Delivered in cloudevents.io JSON format.', lke_audit_logs: 'Kubernetes API server audit logs that capture state-changing operations (mutations) on LKE-E cluster resources.', }; + const pendoIds = { + audit_logs: `Logs Delivery Streams ${capitalizedMode}-Audit Logs`, + lke_audit_logs: `Logs Delivery Streams ${capitalizedMode}-Kubernetes Audit Logs`, + }; + const streamTypeOptionsWithPendo: AutocompleteOption[] = + streamTypeOptions.map((option) => ({ + ...option, + pendoId: pendoIds[option.value as StreamType], + })); const selectedStreamType = useWatch({ control, @@ -53,6 +75,7 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { render={({ field, fieldState }) => ( { name="stream.type" render={({ field, fieldState }) => ( { field.onChange(value); updateStreamDetails(value); }} - options={streamTypeOptions} + options={streamTypeOptionsWithPendo} + renderOption={(props, option, { selected }) => { + return ( +
  • + + {option.label} + + +
  • + ); + }} value={getStreamTypeOption(field.value)} /> )} diff --git a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx index ee4fd0b74de..891c62cd830 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx @@ -26,7 +26,12 @@ export const StreamTableRow = React.memo((props: StreamTableRowProps) => { return ( - {stream.label} + + {stream.label} + {getStreamTypeOption(stream.type)?.label} diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx index e967690eb89..343b78dcc0e 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyState.tsx @@ -35,6 +35,7 @@ export const StreamsLandingEmptyState = ( }); navigateToCreate(); }, + 'data-pendo-id': 'Logs Delivery Streams Empty-Create Stream', }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 4b37661e046..4ad6dddbbf1 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -15,19 +15,19 @@ import { } from 'src/features/Delivery/Shared/types'; import type { + AutocompleteOption, DestinationDetailsForm, FormMode, - LabelValueOption, } from 'src/features/Delivery/Shared/types'; export const getDestinationTypeOption = ( destinationTypeValue: string -): LabelValueOption | undefined => +): AutocompleteOption | undefined => destinationTypeOptions.find(({ value }) => value === destinationTypeValue); export const getStreamTypeOption = ( streamTypeValue: string -): LabelValueOption | undefined => +): AutocompleteOption | undefined => streamTypeOptions.find(({ value }) => value === streamTypeValue); export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; diff --git a/packages/ui/src/components/ActionsPanel/ActionsPanel.tsx b/packages/ui/src/components/ActionsPanel/ActionsPanel.tsx index 5c703543cd7..b89d939a060 100644 --- a/packages/ui/src/components/ActionsPanel/ActionsPanel.tsx +++ b/packages/ui/src/components/ActionsPanel/ActionsPanel.tsx @@ -11,6 +11,7 @@ import type { ButtonProps } from '../Button'; export interface ActionButtonsProps extends ButtonProps { 'data-node-idx'?: number; + 'data-pendo-id'?: string; 'data-qa-form-data-loading'?: boolean; 'data-testid'?: string; label: string; From 72a3e6317baf6b9c52195ed325037621c2a8031d Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 31 Oct 2025 13:06:23 +0100 Subject: [PATCH 03/41] feat: [UIE-9154] - IAM: clean up nodebalancer permissions (#13017) * feat: [UIE-9154] - IAM: clean up nodebalancer permissions * changesets * update perm for nodebalancer_contributor --- .../api-v4/.changeset/pr-13017-changed-1761555478867.md | 5 +++++ packages/api-v4/src/iam/types.ts | 7 ++++--- .../manager/.changeset/pr-13017-changed-1761555510591.md | 5 +++++ .../IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts | 3 ++- 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13017-changed-1761555478867.md create mode 100644 packages/manager/.changeset/pr-13017-changed-1761555510591.md diff --git a/packages/api-v4/.changeset/pr-13017-changed-1761555478867.md b/packages/api-v4/.changeset/pr-13017-changed-1761555478867.md new file mode 100644 index 00000000000..33ca880758e --- /dev/null +++ b/packages/api-v4/.changeset/pr-13017-changed-1761555478867.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +IAM: cleanup for nodebalancer permissions ([#13017](https://github.com/linode/manager/pull/13017)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index a1eb2af7f66..1d14079936a 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -363,8 +363,8 @@ export type LinodeViewer = | 'view_linode_stats'; /** Permissions associated with the "nodebalancer_admin" role. */ -// TODO: UIE-9154 - verify mapping for Nodebalancer as this is not migrated yet export type NodeBalancerAdmin = + | 'create_nodebalancer_config_node' | 'delete_nodebalancer' | 'delete_nodebalancer_config' | 'delete_nodebalancer_config_node' @@ -384,12 +384,13 @@ export type NodeBalancerContributor = /** Permissions associated with the "nodebalancer_viewer" role. */ export type NodeBalancerViewer = | 'list_nodebalancer_config_nodes' - | 'list_nodebalancer_configs' | 'list_nodebalancer_firewalls' + | 'list_nodebalancer_vpc_configs' | 'view_nodebalancer' | 'view_nodebalancer_config' | 'view_nodebalancer_config_node' - | 'view_nodebalancer_statistics'; + | 'view_nodebalancer_statistics' + | 'view_nodebalancer_vpc_config'; /** Permissions associated with the "volume_admin" role. */ export type VolumeAdmin = 'delete_volume' | VolumeContributor; diff --git a/packages/manager/.changeset/pr-13017-changed-1761555510591.md b/packages/manager/.changeset/pr-13017-changed-1761555510591.md new file mode 100644 index 00000000000..c0a6751644c --- /dev/null +++ b/packages/manager/.changeset/pr-13017-changed-1761555510591.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM: clean up in mapping for nodebalancer permissions ([#13017](https://github.com/linode/manager/pull/13017)) diff --git a/packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts index 65a33d807c2..6eb275de82e 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/nodeBalancerGrantsToPermissions.ts @@ -23,9 +23,10 @@ export const nodeBalancerGrantsToPermissions = ( view_nodebalancer: unrestricted || grantLevel !== null, list_nodebalancer_firewalls: unrestricted || grantLevel !== null, view_nodebalancer_statistics: unrestricted || grantLevel !== null, - list_nodebalancer_configs: unrestricted || grantLevel !== null, + list_nodebalancer_vpc_configs: unrestricted || grantLevel !== null, view_nodebalancer_config: unrestricted || grantLevel !== null, list_nodebalancer_config_nodes: unrestricted || grantLevel !== null, view_nodebalancer_config_node: unrestricted || grantLevel !== null, + view_nodebalancer_vpc_config: unrestricted || grantLevel !== null, }; }; From 1d18a038bb45da121cb45c2baaf352175b96e902 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:03:00 +0100 Subject: [PATCH 04/41] fix: [UIE-9456 & UIE-9457] - Typo + expose search filters on /iam/roles route (#13034) * UIE-9456 & UIE-9457 * test * Added changeset: Typo + expose search filters on /iam/roles route --- .../pr-13034-fixed-1761754563010.md | 5 ++ .../RolesTable/AssignSelectedRolesDrawer.tsx | 2 +- .../IAM/Roles/RolesTable/RolesTable.test.tsx | 81 ++++++++++--------- .../IAM/Roles/RolesTable/RolesTable.tsx | 25 ++++-- 4 files changed, 67 insertions(+), 46 deletions(-) create mode 100644 packages/manager/.changeset/pr-13034-fixed-1761754563010.md diff --git a/packages/manager/.changeset/pr-13034-fixed-1761754563010.md b/packages/manager/.changeset/pr-13034-fixed-1761754563010.md new file mode 100644 index 00000000000..3526073b2e0 --- /dev/null +++ b/packages/manager/.changeset/pr-13034-fixed-1761754563010.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Typo + expose search filters on /iam/roles route ([#13034](https://github.com/linode/manager/pull/13034)) diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index 40fec42a1ea..9194cfd11b1 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -179,7 +179,7 @@ export const AssignSelectedRolesDrawer = ({ })} > - Users + User ({ usePermissions: vi.fn(), -}; + useSearch: vi.fn(), +})); vi.mock('src/features/IAM/Shared/utilities', async () => { - const actual = await vi.importActual( - 'src/features/IAM/Shared/utilities' - ); + const actual = await vi.importActual('src/features/IAM/Shared/utilities'); return { ...actual, mapAccountPermissionsToRoles: vi.fn(), @@ -22,15 +21,21 @@ vi.mock('src/features/IAM/Shared/utilities', async () => { }); vi.mock('src/features/IAM/hooks/usePermissions', async () => { - const actual = await vi.importActual( - 'src/features/IAM/hooks/usePermissions' - ); + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); return { ...actual, usePermissions: vi.fn().mockReturnValue({}), }; }); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useSearch: queryMocks.useSearch, + }; +}); + const mockRoles: RoleView[] = [ { access: 'account_access', @@ -63,52 +68,54 @@ beforeEach(() => { }); describe('RolesTable', () => { + beforeEach(() => { + queryMocks.useSearch.mockReturnValue({ + query: '', + }); + }); + it('renders no roles when roles array is empty', async () => { - const { getByText, getByTestId } = renderWithTheme( - - ); + renderWithTheme(); - expect(getByTestId('roles-table')).toBeInTheDocument(); - expect(getByText('No items to display.')).toBeInTheDocument(); + screen.getByTestId('roles-table'); + screen.getByText('No items to display.'); }); it('renders roles correctly when roles array is provided', async () => { - const { getByText, getByTestId, getAllByRole } = renderWithTheme( - - ); + const { getAllByRole } = renderWithTheme(); - expect(getByTestId('roles-table')).toBeInTheDocument(); + screen.getByTestId('roles-table'); expect(getAllByRole('combobox').length).toEqual(1); - expect(getByText('Account linode admin')).toBeInTheDocument(); + screen.getByText('Account linode admin'); }); it('filters roles to warranted results based on search input', async () => { + queryMocks.useSearch.mockReturnValue({ + query: 'Account', + }); + renderWithTheme(); + const searchInput: HTMLInputElement = screen.getByPlaceholderText('Search'); - fireEvent.change(searchInput, { target: { value: 'Account' } }); - - await waitFor(() => { - expect(screen.getByTestId('roles-table')).toBeInTheDocument(); - expect(searchInput.value).toBe('Account'); - // TODO - if there is a way to pierce the shadow DOM, we can check these results, but these tests fail currently - // expect(screen.getByText('Account')).toBeInTheDocument(); - // expect(screen.queryByText('Database')).not.toBeInTheDocument(); - // expect(screen.getByText('No items to display.')).not.toBeInTheDocument(); - }); + + screen.getByTestId('roles-table'); + + expect(searchInput.value).toBe('Account'); + expect(screen.queryByText('Database')).not.toBeInTheDocument(); + expect(screen.queryByText('No items to display.')).not.toBeInTheDocument(); }); it('filters roles to no results based on search input if warranted', async () => { + queryMocks.useSearch.mockReturnValue({ + query: 'NonsenseThatWontMatchAnything', + }); + renderWithTheme(); const searchInput: HTMLInputElement = screen.getByPlaceholderText('Search'); - fireEvent.change(searchInput, { - target: { value: 'NonsenseThatWontMatchAnything' }, - }); - await waitFor(() => { - expect(screen.getByTestId('roles-table')).toBeInTheDocument(); - expect(searchInput.value).toBe('NonsenseThatWontMatchAnything'); - expect(screen.getByText('No items to display.')).toBeInTheDocument(); - }); + screen.getByTestId('roles-table'); + expect(searchInput.value).toBe('NonsenseThatWontMatchAnything'); + screen.getByText('No items to display.'); }); }); diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index 29abe650844..c5698da5077 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -3,6 +3,7 @@ import { capitalizeAllWords } from '@linode/utilities'; import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; import { Pagination } from 'akamai-cds-react-components/Pagination'; import { sortRows, @@ -59,8 +60,12 @@ const DEFAULT_PAGE_SIZE = 10; export const RolesTable = ({ roles = [] }: Props) => { const theme = useTheme(); - // Filter string for the search bar - const [filterString, setFilterString] = React.useState(''); + const navigate = useNavigate(); + const location = useLocation(); + const { query } = useSearch({ + strict: false, + }); + const [filterableEntityType, setFilterableEntityType] = useState(ALL_ROLES_OPTION); const [sort, setSort] = useState< @@ -94,10 +99,11 @@ export const RolesTable = ({ roles = [] }: Props) => { ); }; - const filteredRows = React.useMemo( - () => getFilteredRows(filterString, filterableEntityType?.value), - [roles, filterString, filterableEntityType] - ); + const filteredRows = React.useMemo(() => { + if (!query) return roles; + + return getFilteredRows(query, filterableEntityType?.value); + }, [roles, query, filterableEntityType]); // Get just the list of entity types from this list of roles, to be used in the selection filter const filterableOptions = React.useMemo(() => { @@ -137,7 +143,10 @@ export const RolesTable = ({ roles = [] }: Props) => { }; const handleTextFilter = (fs: string) => { - setFilterString(fs); + navigate({ + to: location.pathname, + search: { query: fs !== '' ? fs : undefined }, + }); pagination.handlePageChange(1); }; @@ -199,7 +208,7 @@ export const RolesTable = ({ roles = [] }: Props) => { label="Search" onSearch={handleTextFilter} placeholder="Search" - value={filterString} + value={query ?? ''} />