From c82ab92ce2de9dbf110313e8d6a71a94745897a6 Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:19:57 -0400 Subject: [PATCH 01/39] fix: [M3-10518] - Fix LKE-E node pool drawer test that's broken in DevCloud (#12884) * M3-10518 Fix LKE-E node pool drawer test that's broken in DevCloud * Added changeset: Fix lke-update.spec.ts LKE-E node pool drawer test that's broken in DevCloud --- .../pr-12884-tests-1758049882650.md | 5 ++ .../e2e/core/kubernetes/lke-update.spec.ts | 71 +++++++++++-------- 2 files changed, 47 insertions(+), 29 deletions(-) create mode 100644 packages/manager/.changeset/pr-12884-tests-1758049882650.md diff --git a/packages/manager/.changeset/pr-12884-tests-1758049882650.md b/packages/manager/.changeset/pr-12884-tests-1758049882650.md new file mode 100644 index 00000000000..ef1c6bddfb9 --- /dev/null +++ b/packages/manager/.changeset/pr-12884-tests-1758049882650.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix "lke-update.spec.ts" LKE-E node pool drawer test that's broken in DevCloud ([#12884](https://github.com/linode/manager/pull/12884)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index f80a54e3809..07bd5f6edd6 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1239,11 +1239,20 @@ describe('LKE cluster updates', () => { }); it('can add a node pool with an update strategy on an LKE enterprise cluster', () => { - const cluster = kubernetesClusterFactory.build({ + const clusterRegion = regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-east', + }); + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: clusterRegion.id, tier: 'enterprise', }); + const mockNodePool = nodePoolFactory.build({ + type: 'g6-dedicated-4', + }); const account = accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], }); const type = linodeTypeFactory.build({ class: 'dedicated', @@ -1258,13 +1267,15 @@ describe('LKE cluster updates', () => { }); mockGetAccount(account).as('getAccount'); - mockGetCluster(cluster).as('getCluster'); - mockGetClusterPools(cluster.id, []).as('getNodePools'); + mockGetRegions([clusterRegion]).as('getRegions'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetClusterPools(mockCluster.id, []).as('getNodePools'); mockGetLinodeTypes([type]).as('getTypes'); - cy.visitWithLogin(`/kubernetes/clusters/${cluster.id}`); + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getAccount']); + cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getRegions']); ui.button .findByTitle('Add a Node Pool') @@ -1282,33 +1293,35 @@ describe('LKE cluster updates', () => { cy.findByLabelText('Add 1').should('be.enabled').click(); }); - interceptCreateNodePool(cluster.id).as('createNodePool'); + interceptCreateNodePool(mockCluster.id).as('createNodePool'); - ui.drawer.findByTitle(`Add a Node Pool: ${cluster.label}`).within(() => { - cy.findByLabelText('Update Strategy') - .should('be.visible') - .should('be.enabled') - .should('have.value', 'On Recycle Updates') // Should default to "On Recycle" - .click(); // Open the Autocomplete + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .within(() => { + cy.findByLabelText('Update Strategy') + .should('be.visible') + .should('be.enabled') + .should('have.value', 'On Recycle Updates') // Should default to "On Recycle" + .click(); // Open the Autocomplete - ui.autocompletePopper - .findByTitle('Rolling Updates') // Select "Rolling Updates" - .should('be.visible') - .should('be.enabled') - .click(); + ui.autocompletePopper + .findByTitle('Rolling Updates') // Select "Rolling Updates" + .should('be.visible') + .should('be.enabled') + .click(); - // Verify the field's value actually changed - cy.findByLabelText('Update Strategy').should( - 'have.value', - 'Rolling Updates' - ); + // Verify the field's value actually changed + cy.findByLabelText('Update Strategy').should( + 'have.value', + 'Rolling Updates' + ); - ui.button - .findByTitle('Add pool') - .should('be.enabled') - .should('be.visible') - .click(); - }); + ui.button + .findByTitle('Add pool') + .should('be.enabled') + .should('be.visible') + .click(); + }); cy.wait('@createNodePool').then((intercept) => { const payload = intercept.request.body; From 51444b7d11ff958ac7fdca43c419a719dbf026ed Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:07:11 -0400 Subject: [PATCH 02/39] fix: [M3-10679] - NodeBalancer Configuration form unresponsiveness for larger VPC deployments (#12991) * use vpc ips endpoint (work in progress) * improve things more * clean up * fix typecheck * other bug fixes * Added changeset: NodeBalancer Configuration form unresponsiveness for larger VPC deployments * update cypress tests * make code a bit safer * Apply suggestion from @dwiley-akamai Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> * Apply suggestion from @dwiley-akamai Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --------- Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- .../pr-12991-fixed-1760562047563.md | 5 ++ .../nodebalancer-create-with-vpc.spec.ts | 59 +++++---------- ...debalancers-create-in-complex-form.spec.ts | 2 +- .../manager/cypress/support/intercepts/vpc.ts | 21 +++++- .../NodeBalancers/ConfigNodeIPSelect.tsx | 14 ++-- .../NodeBalancers/ConfigNodeIPSelect.utils.ts | 69 +++++++----------- .../NodeBalancers/NodeBalancerConfigNode.tsx | 2 + .../src/hooks/useDataForLinodesInVPC.ts | 73 ++++++------------- packages/queries/src/linodes/linodes.ts | 8 ++ packages/queries/src/vpcs/vpcs.ts | 4 +- 10 files changed, 114 insertions(+), 143 deletions(-) create mode 100644 packages/manager/.changeset/pr-12991-fixed-1760562047563.md diff --git a/packages/manager/.changeset/pr-12991-fixed-1760562047563.md b/packages/manager/.changeset/pr-12991-fixed-1760562047563.md new file mode 100644 index 00000000000..a307ee47967 --- /dev/null +++ b/packages/manager/.changeset/pr-12991-fixed-1760562047563.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +NodeBalancer Configuration form unresponsiveness for larger VPC deployments ([#12991](https://github.com/linode/manager/pull/12991)) diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts index 9331704d64a..2cd454153fb 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts @@ -1,15 +1,12 @@ import { linodeFactory, nodeBalancerFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { - mockGetLinodeIPAddresses, - mockGetLinodes, -} from 'support/intercepts/linodes'; +import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockCreateNodeBalancer, mockGetNodeBalancer, } from 'support/intercepts/nodebalancers'; -import { mockGetSubnets, mockGetVPCs } from 'support/intercepts/vpc'; +import { mockGetVPC, mockGetVPCIPs, mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import { randomIp, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -28,24 +25,29 @@ describe('Create a NodeBalancer with VPCs', () => { capabilities: ['VPCs', 'NodeBalancers'], }); - const mockSubnet = subnetFactory.build({ + const mockLinode = linodeFactory.build({ id: randomNumber(), - ipv4: `10.0.0.0/24`, label: randomLabel(), - linodes: [], + region: region.id, }); - const mockVPC = vpcFactory.build({ + const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: `10.0.0.0/24`, label: randomLabel(), - region: region.id, - subnets: [mockSubnet], + linodes: [ + { + id: mockLinode.id, + interfaces: [], + }, + ], }); - const mockLinode = linodeFactory.build({ + const mockVPC = vpcFactory.build({ id: randomNumber(), label: randomLabel(), region: region.id, + subnets: [mockSubnet], }); const mockLinodeVPCIPv4 = vpcIPv4Factory.build({ @@ -63,40 +65,16 @@ describe('Create a NodeBalancer with VPCs', () => { ipv4: randomIp(), }); - const mockUpdatedSubnet = { - ...mockSubnet, - linodes: [ - { - id: mockLinode.id, - interfaces: [], - }, - ], - nodebalancers: [ - { - id: mockNodeBalancer.id, - ipv4_range: '10.0.0.4/30', - }, - ], - }; - mockAppendFeatureFlags({ nodebalancerVpc: true, }).as('getFeatureFlags'); mockGetVPCs([mockVPC]).as('getVPCs'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); + mockGetVPC(mockVPC).as('getVPC'); mockGetLinodes([mockLinode]).as('getLinodes'); mockCreateNodeBalancer(mockNodeBalancer).as('createNodeBalancer'); mockGetNodeBalancer(mockNodeBalancer); - mockGetLinodeIPAddresses(mockLinode.id, { - ipv4: { - private: [], - public: [], - reserved: [], - shared: [], - vpc: [mockLinodeVPCIPv4], - }, - }).as('getLinodeIPAddresses'); + mockGetVPCIPs(mockVPC.id, [mockLinodeVPCIPv4]).as('getVPCIPs'); cy.visitWithLogin('/nodebalancers/create'); cy.wait('@getFeatureFlags'); @@ -118,6 +96,9 @@ describe('Create a NodeBalancer with VPCs', () => { .should('be.visible') .click(); + // The "Node IP Address Select" should fetch the selected VPC and its IPs to render options. + cy.wait(['@getVPC', '@getVPCIPs']); + // Confirm that VPC's subnet gets selected cy.findByLabelText('Subnet').should( 'have.value', @@ -132,7 +113,7 @@ describe('Create a NodeBalancer with VPCs', () => { cy.findByText(`NodeBalancer IPv4 CIDR for ${mockSubnet.label}`).click(); cy.focused().clear(); - cy.focused().type(`${mockUpdatedSubnet.nodebalancers[0].ipv4_range}`); + cy.focused().type('10.0.0.4/30'); // node backend config cy.findByText('Label').click(); cy.focused().type(randomLabel()); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts index 8f4da5a306b..37e315110ed 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts @@ -88,7 +88,7 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi .findByTitle(nodeBal_2.ipv4) .should('be.visible') .click(); - cy.findByLabelText('Weight').should('be.visible').click(); + cy.findAllByLabelText('Weight').last().should('be.visible').click(); cy.focused().clear(); cy.focused().type('50'); diff --git a/packages/manager/cypress/support/intercepts/vpc.ts b/packages/manager/cypress/support/intercepts/vpc.ts index d2c179b8520..2fa804ef810 100644 --- a/packages/manager/cypress/support/intercepts/vpc.ts +++ b/packages/manager/cypress/support/intercepts/vpc.ts @@ -7,7 +7,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Subnet, VPC } from '@linode/api-v4'; +import type { Subnet, VPC, VPCIP } from '@linode/api-v4'; export const MOCK_DELETE_VPC_ERROR = 'Before deleting this VPC, you must remove all of its Linodes'; @@ -34,6 +34,25 @@ export const mockGetVPCs = (vpcs: VPC[]): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('vpcs*'), paginateResponse(vpcs)); }; +/** + * Intercepts GET request to fetch a VPC's IPs and mocks response. + * + * @param vpcId - The ID of the VPC. + * @param vpcIPs - Array of VPC IPs with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetVPCIPs = ( + vpcId: number, + vpcIPs: VPCIP[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`vpcs/${vpcId}/ips*`), + paginateResponse(vpcIPs) + ); +}; + /** * Intercepts POST request to create a VPC and mocks the response. * diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index 967150688d7..c3372bc445d 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -70,19 +70,17 @@ export const ConfigNodeIPSelect = React.memo((props: Props) => { subnetId, } = props; - const { linodesData, linodeIpsData, error, isLoading, subnetsData } = - useGetLinodeIPAndVPCData({ - region, - vpcId, - subnetId, - }); + const { linodes, error, isLoading, vpc, vpcIPs } = useGetLinodeIPAndVPCData({ + region, + vpcId, + }); let options: NodeOption[] = []; if (region && !vpcId) { - options = getPrivateIPOptions(linodesData); + options = getPrivateIPOptions(linodes); } else if (region && vpcId && subnetId) { - options = getVPCIPOptions(linodeIpsData, linodesData, subnetsData?.data); + options = getVPCIPOptions(vpcIPs, linodes, vpc?.subnets); } const noOptionsText = useMemo(() => { diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts index 48e647b9c89..d3c8858dbbe 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts @@ -1,4 +1,6 @@ -import type { Linode, LinodeIPsResponse, Subnet } from '@linode/api-v4'; +import { listToItemsByID } from '@linode/queries'; + +import type { Linode, Subnet, VPCIP } from '@linode/api-v4'; export interface PrivateIPOption { /** @@ -8,14 +10,14 @@ export interface PrivateIPOption { /** * The Linode associated with the private IPv4 address */ - linode: Partial; + linode: Linode; } export interface VPCIPOption extends PrivateIPOption { /** * The Subnet associated with the VPC IPv4 address */ - subnet: Partial; + subnet: Subnet; } /** @@ -40,55 +42,38 @@ export const getPrivateIPOptions = (linodes: Linode[] | undefined) => { }; export const getVPCIPOptions = ( - vpcIps: LinodeIPsResponse[] | undefined, + vpcIps: undefined | VPCIP[], linodes: Linode[] | undefined, - subnets?: Subnet[] | undefined + subnets: Subnet[] | undefined ) => { if (!vpcIps || !subnets) { return []; } + const linodesMap = listToItemsByID(linodes ?? [], 'id'); + const subnetsMap = listToItemsByID(subnets ?? [], 'id'); + const options: VPCIPOption[] = []; - const linodeLabelMap = (linodes ?? []).reduce( - (acc: Record, linode) => { - acc[linode.id] = linode.label; - return acc; - }, - {} - ); - const subnetLabelMap = (subnets ?? []).reduce( - (acc: Record, subnet) => { - acc[subnet.id] = subnet.label; - return acc; - }, - {} - ); + for (const ip of vpcIps) { + if (!ip.address || !ip.linode_id) { + continue; + } - vpcIps.forEach(({ ipv4 }) => { - if (ipv4.vpc) { - const vpcData = ipv4.vpc - .filter((vpc) => vpc.address && vpc.subnet_id in subnetLabelMap) - .map((vpc) => { - const linode: Partial = { - label: linodeLabelMap[vpc.linode_id], - id: vpc.linode_id, - }; - return { - label: vpc.address, - linode, - subnet: { - id: vpc.subnet_id, - label: subnetLabelMap[vpc.subnet_id], - }, - }; - }); + const subnet = subnetsMap[ip.subnet_id]; + const linode = linodesMap[ip.linode_id]; - if (vpcData) { - options.push(...vpcData); - } + if (!linode || !subnet) { + // Safeguard against linode or subnet being undefined + continue; } - }); - return options.sort((a, b) => a.label.localeCompare(b.label)); + options.push({ + label: ip.address, + subnet, + linode, + }); + } + + return options; }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index 2a21eee4f9f..fc5b49e8660 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -188,6 +188,7 @@ export const NodeBalancerConfigNode = React.memo( disabled={disabled} errorGroup={`${configIdx}`} errorText={nodesErrorMap.port} + inputId={`node-port-${configIdx}-${idx}`} inputProps={{ 'data-node-idx': idx }} label="Port" noMarginTop @@ -208,6 +209,7 @@ export const NodeBalancerConfigNode = React.memo( disabled={disabled} errorGroup={`${configIdx}`} errorText={nodesErrorMap.weight} + inputId={`node-weight-${configIdx}-${idx}`} inputProps={{ 'data-node-idx': idx }} label="Weight" noMarginTop diff --git a/packages/manager/src/hooks/useDataForLinodesInVPC.ts b/packages/manager/src/hooks/useDataForLinodesInVPC.ts index 174803463fc..07f15b693ba 100644 --- a/packages/manager/src/hooks/useDataForLinodesInVPC.ts +++ b/packages/manager/src/hooks/useDataForLinodesInVPC.ts @@ -1,77 +1,50 @@ import { - linodeQueries, useAllLinodesQuery, - useQueries, - useSubnetsQuery, + useVPCIPsQuery, + useVPCQuery, } from '@linode/queries'; -import { useMemo } from 'react'; - -import type { APIError, LinodeIPsResponse } from '@linode/api-v4'; -import type { UseQueryOptions } from '@linode/queries'; export const useGetLinodeIPAndVPCData = (props: { region?: string; - subnetId?: number; vpcId?: null | number; }) => { - const { region, vpcId, subnetId } = props; - - const isSubnetSelected = useMemo( - () => vpcId !== undefined && subnetId !== undefined, - [vpcId, subnetId] - ); + const { region, vpcId } = props; const { - data: linodesData, + data: linodes, error: linodesError, isLoading: linodesIsLoading, } = useAllLinodesQuery({}, { region }, region !== undefined); const { - data: subnetsData, - error: subnetsError, - isLoading: subnetsIsLoading, - } = useSubnetsQuery(Number(vpcId), {}, {}, isSubnetSelected); + data: vpc, + error: vpcError, + isLoading: vpcLoading, + } = useVPCQuery(vpcId ?? -1, vpcId !== undefined); - const linodeIPQueries = useQueries({ - queries: - linodesData?.map>( - ({ id }) => ({ - ...linodeQueries.linode(id)._ctx.ips, - enabled: isSubnetSelected, - }) - ) ?? [], - }); + const { + data: vpcIPs, + error: vpcIPsError, + isLoading: isVPCIPsLoading, + } = useVPCIPsQuery(vpcId ?? -1, {}, vpcId !== undefined); if (region && !vpcId) { return { - linodesData, + linodes, error: linodesError, isLoading: linodesIsLoading, }; } - const linodeIpsData: LinodeIPsResponse[] = []; - let isIpLoading: boolean = false; - const ipError: APIError[] = []; - - linodeIPQueries.forEach(({ data, isLoading, error }) => { - if (data) { - linodeIpsData.push(data); - } - if (isLoading) { - isIpLoading = true; - } - if (error) { - ipError.push(...error); - } - }); - return { - linodesData, - linodeIpsData, - error: [...(linodesError ?? []), ...(subnetsError ?? []), ...ipError], - isLoading: linodesIsLoading ?? subnetsIsLoading ?? isIpLoading, - subnetsData, + linodes, + vpcIPs, + error: [ + ...(linodesError ?? []), + ...(vpcError ?? []), + ...(vpcIPsError ?? []), + ], + isLoading: linodesIsLoading ?? vpcLoading ?? isVPCIPsLoading, + vpc, }; }; diff --git a/packages/queries/src/linodes/linodes.ts b/packages/queries/src/linodes/linodes.ts index 6867e423043..26ca14a74c0 100644 --- a/packages/queries/src/linodes/linodes.ts +++ b/packages/queries/src/linodes/linodes.ts @@ -383,6 +383,14 @@ export const useCreateLinodeMutation = () => { }); } + // Invalidate all VPC queries if the Linode was created with a VPC. + // We have to invalidate all VPC queries because the new "Linode Interfaces" payload + // does not include the VPC ID. It only includes the Subnet ID. + // The VPC ID is necessary for more granular invalidation, but it is not available here. + if (variables.interfaces?.some((i) => i.vpc)) { + queryClient.invalidateQueries({ queryKey: vpcQueries._def }); + } + for (const linodeInterface of variables.interfaces ?? []) { if (linodeInterface.firewall_id) { // If the interface has a Firewall, invalidate that Firewall diff --git a/packages/queries/src/vpcs/vpcs.ts b/packages/queries/src/vpcs/vpcs.ts index 61c4dea3e08..0ac1c78d075 100644 --- a/packages/queries/src/vpcs/vpcs.ts +++ b/packages/queries/src/vpcs/vpcs.ts @@ -65,7 +65,7 @@ export const vpcQueries = createQueryKeys('vpcs', { }, queryKey: null, }, - vpcIps: (vpcId: number, filter: Filter = {}) => ({ + vpcIps: (filter: Filter = {}) => ({ queryFn: () => getAllVPCIPsRequest(vpcId, filter), queryKey: [filter], }), @@ -121,7 +121,7 @@ export const useVPCIPsQuery = ( enabled: boolean = false, ) => useQuery({ - ...vpcQueries.vpc(id)._ctx.vpcIps(id, filter), + ...vpcQueries.vpc(id)._ctx.vpcIps(filter), enabled, }); From f25d6284a9b6006da9d1677940b15145aff248ca Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:03:07 -0400 Subject: [PATCH 03/39] refactor: [UIE-9326] - Replace Formik with React Hook Form in Database Create (#12975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Refactor Database Create flow to use React Hook Form instead of Formik ## Changes 🔄 - There should be no visual external changes other than some minor adjustments to the Networking section: - IP Address/range validation on blur instead of after clicking Create - Minor spacing adjustments ### Scope 🚢 Upon production release, changes in this PR will be visible to: - [x] All customers - [ ] Some customers (e.g. in Beta or Limited Availability) - [ ] No customers / Not applicable ## How to test 🧪 ### Verification steps (How to verify changes) - [ ] Go to `/databases/create` - [ ] Try to Create some Databases with different engines/regions/plans, with/without IP addresses, with/without VPC - [ ] There should be no functional regressions compared to Prod --- packages/api-v4/src/databases/types.ts | 2 +- .../DatabaseCreate/DatabaseClusterData.tsx | 159 +++--- .../DatabaseCreate/DatabaseCreate.tsx | 471 ++++++++---------- .../DatabaseCreateAccessControls.test.tsx | 39 +- .../DatabaseCreateAccessControls.tsx | 160 +++--- ...baseCreateNetworkingConfiguration.test.tsx | 18 +- .../DatabaseCreateNetworkingConfiguration.tsx | 31 +- .../DatabaseCreate/DatabaseEngineSelect.tsx | 135 ++--- .../DatabaseVPCSelector.test.tsx | 3 +- .../DatabaseCreate/DatabaseVPCSelector.tsx | 243 ++++----- .../DatabaseManageNetworkingDrawer.tsx | 2 +- .../DatabaseVPCSelector.tsx | 243 +++++++++ packages/validation/src/databases.schema.ts | 2 - 13 files changed, 823 insertions(+), 685 deletions(-) create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index f9356701066..694493768e3 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -139,7 +139,7 @@ type ReadonlyCount = 0 | 2; export type MySQLReplicationType = 'asynch' | 'none' | 'semi_synch'; export interface CreateDatabasePayload { - allow_list?: string[]; + allow_list: string[]; cluster_size?: ClusterSize; /** @Deprecated used by rdbms-legacy only, rdbms-default always encrypts */ encrypted?: boolean; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx index ae6c3ff1b62..05ec1fe03eb 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx @@ -1,7 +1,10 @@ +import { useRegionsQuery } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { Divider, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid'; +import { getCapabilityFromPlanType } from '@linode/utilities'; +import Box from '@mui/material/Box'; import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; @@ -13,36 +16,26 @@ import { DatabaseEngineSelect } from 'src/features/Databases/DatabaseCreate/Data import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import type { - ClusterSize, - DatabaseEngine, - Engine, - PrivateNetwork, - Region, -} from '@linode/api-v4'; -import type { FormikErrors } from 'formik'; -export interface DatabaseCreateValues { - allow_list: { - address: string; - error: string; - }[]; - cluster_size: ClusterSize; - engine: Engine; - label: string; - private_network: PrivateNetwork; - region: string; - type: string; -} +import type { DatabaseCreateValues } from './DatabaseCreate'; +import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; interface Props { - engines: DatabaseEngine[] | undefined; - errors: FormikErrors; - onChange: (filed: string, value: any) => void; - regionsData: Region[]; - values: DatabaseCreateValues; + selectedPlan?: PlanSelectionWithDatabaseType; } + +const labelToolTip = ( + + Label must: +
    +
  • Begin with an alpha character
  • +
  • Contain only alpha characters or single hyphens
  • +
  • Be between 3 - 32 characters
  • +
+
+); + export const DatabaseClusterData = (props: Props) => { - const { engines, errors, onChange, regionsData, values } = props; + const { selectedPlan } = props; const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_databases', }); @@ -52,54 +45,88 @@ export const DatabaseClusterData = (props: Props) => { flags.gecko2?.la ); - const labelToolTip = ( - - Label must: -
    -
  • Begin with an alpha character
  • -
  • Contain only alpha characters or single hyphens
  • -
  • Be between 3 - 32 characters
  • -
-
- ); + const { data: regionsData } = useRegionsQuery(); + + const { control, setValue, reset, getValues } = + useFormContext(); + + const resetVPCConfiguration = () => { + reset({ + ...getValues(), + private_network: { + vpc_id: null, + subnet_id: null, + public_access: false, + }, + }); + }; + + const handleRegionChange = (value: string) => { + setValue('region', value); + + // When the selected region has changed, reset VPC configuration + resetVPCConfiguration(); + + // Validate plan selection + if (flags.databasePremium && selectedPlan) { + const newRegion = regionsData?.find((region) => region.id === value); + + const isPlanAvailableInRegion = Boolean( + newRegion?.capabilities.includes( + getCapabilityFromPlanType(selectedPlan.class) + ) + ); + // Clear plan selection if plan is not available in the selected region + if (!isPlanAvailableInRegion) { + setValue('type', ''); + } + } + }; return ( <> - + Name Your Cluster - onChange('label', e.target.value)} - tooltipText={labelToolTip} - value={values.label} + ( + + )} /> - + - + Select Engine and Region - - - - onChange('region', region.id)} - regions={regionsData} - value={values.region} + + + + ( + handleRegionChange(region.id)} + regions={regionsData ?? []} + value={field.value ?? undefined} + /> + )} /> - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 15080cb3e19..d9b3b0d8aa2 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -1,3 +1,4 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useCreateDatabaseMutation, useDatabaseEnginesQuery, @@ -6,16 +7,12 @@ import { useRegionsQuery, } from '@linode/queries'; import { CircleProgress, Divider, ErrorState, Notice, Paper } from '@linode/ui'; -import { - formatStorageUnits, - getCapabilityFromPlanType, - scrollErrorIntoViewV2, -} from '@linode/utilities'; +import { formatStorageUnits, scrollErrorIntoViewV2 } from '@linode/utilities'; import { getDynamicDatabaseSchema } from '@linode/validation/lib/databases.schema'; import Grid from '@mui/material/Grid'; import { useNavigate } from '@tanstack/react-router'; -import { useFormik } from 'formik'; import * as React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorMessage } from 'src/components/ErrorMessage'; @@ -32,14 +29,10 @@ import { import { DatabaseNodeSelector } from 'src/features/Databases/DatabaseCreate/DatabaseNodeSelector'; import { DatabaseSummarySection } from 'src/features/Databases/DatabaseCreate/DatabaseSummarySection'; import { DatabaseLogo } from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; -import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; -import { validateIPs } from 'src/utilities/ipUtils'; -import { ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT } from '../constants'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; import { DatabaseCreateNetworkingConfiguration } from './DatabaseCreateNetworkingConfiguration'; @@ -48,13 +41,23 @@ import type { ClusterSize, CreateDatabasePayload, Engine, + PrivateNetwork, VPC, } from '@linode/api-v4/lib/databases/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; -import type { DatabaseCreateValues } from 'src/features/Databases/DatabaseCreate/DatabaseClusterData'; import type { ExtendedIP } from 'src/utilities/ipUtils'; +export interface DatabaseCreateValues { + allow_list: ExtendedIP[]; + cluster_size: ClusterSize; + engine: Engine; + label: string; + private_network?: PrivateNetwork; + region: string; + type: string; +} + export const DatabaseCreate = () => { const navigate = useNavigate(); const isRestricted = useRestrictedGlobalGrantCheck({ @@ -66,11 +69,8 @@ export const DatabaseCreate = () => { isLoading: regionsLoading, } = useRegionsQuery(); - const { - data: engines, - error: enginesError, - isLoading: enginesLoading, - } = useDatabaseEnginesQuery(true); + const { error: enginesError, isLoading: enginesLoading } = + useDatabaseEnginesQuery(true); const { data: dbtypes, @@ -86,89 +86,11 @@ export const DatabaseCreate = () => { const formRef = React.useRef(null); const { mutateAsync: createDatabase } = useCreateDatabaseMutation(); - const [createError, setCreateError] = React.useState(); const [ipErrorsFromAPI, setIPErrorsFromAPI] = React.useState(); const [selectedTab, setSelectedTab] = React.useState(0); const [selectedVPC, setSelectedVPC] = React.useState(null); const isVPCSelected = Boolean(selectedVPC); - const handleIPBlur = (ips: ExtendedIP[]) => { - const ipsWithMasks = enforceIPMasks(ips); - setFieldValue('allow_list', ipsWithMasks); - }; - - const handleIPValidation = () => { - const validatedIps = validateIPs(values.allow_list, { - allowEmptyAddress: true, - errorMessage: ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, - }); - - if (validatedIps.some((ip) => ip.error)) { - setFieldValue('allow_list', validatedIps); - } else { - setFieldValue( - 'allow_list', - validatedIps.map((ip) => { - delete ip.error; - return { - ...ip, - }; - }) - ); - } - }; - - const submitForm = async () => { - if (values.allow_list.some((ip) => ip.error)) { - return; - } - - setCreateError(undefined); - setSubmitting(true); - - const _allow_list = values.allow_list.reduce((accum, ip) => { - if (ip.address !== '') { - return [...accum, ip.address]; - } - return accum; - }, []); - const hasVpc = - values.private_network.vpc_id && values.private_network.subnet_id; - const privateNetwork = hasVpc ? values.private_network : null; - - const createPayload: CreateDatabasePayload = { - ...values, - allow_list: _allow_list, - private_network: privateNetwork, - }; - - // TODO (UIE-8831): Remove post VPC release, since it will always be in create payload - if (!isVPCEnabled) { - delete createPayload.private_network; - } - try { - const response = await createDatabase(createPayload); - navigate({ - to: `/databases/$engine/$databaseId`, - params: { - engine: response.engine, - databaseId: response.id, - }, - }); - } catch (errors) { - const ipErrors = errors.filter( - (error: APIError) => error.field === 'allow_list' - ); - if (ipErrors) { - setIPErrorsFromAPI(ipErrors); - } - const parentFields = ['private_network']; // List of parent fields that need the full key from the errors response - handleAPIErrors(errors, setFieldError, setCreateError, parentFields); - } - - setSubmitting(false); - }; - const initialValues: DatabaseCreateValues = { allow_list: [ { @@ -176,7 +98,7 @@ export const DatabaseCreate = () => { error: '', }, ], - cluster_size: -1 as ClusterSize, + cluster_size: 3, engine: 'mysql/8' as Engine, label: '', region: '', @@ -188,41 +110,36 @@ export const DatabaseCreate = () => { }, }; + const form = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + // @ts-expect-error allow_list gets transformed to an array of strings in the onSubmit function + resolver: yupResolver(getDynamicDatabaseSchema(isVPCSelected)), + }); + const { - errors, + control, + formState: { isSubmitting, errors }, handleSubmit, - isSubmitting, - resetForm, - setFieldError, - setFieldValue, - setSubmitting, - values, - } = useFormik({ - initialValues, - onSubmit: submitForm, - validate: () => { - handleIPValidation(); - scrollErrorIntoViewV2(formRef); - }, - validateOnChange: false, - validationSchema: getDynamicDatabaseSchema(isVPCSelected), - }); // TODO (UIE-8903): Replace deprecated Formik with React Hook Form + setError, + setValue, + watch, + } = form; + + const [allowList, clusterSize, region, type, engine] = watch([ + 'allow_list', + 'cluster_size', + 'region', + 'type', + 'engine', + ]); const { data: regionAvailabilities } = useRegionAvailabilityQuery( - values.region || '', - Boolean(flags.soldOutChips) && Boolean(values.region) + region || '', + Boolean(flags.soldOutChips) && Boolean(region) ); - React.useEffect(() => { - if (setFieldValue) { - setFieldValue( - 'cluster_size', - values.cluster_size < 1 ? 3 : values.cluster_size - ); - } - }, [setFieldValue, values.cluster_size, values.engine]); - - const selectedEngine = values.engine.split('/')[0] as Engine; + const selectedEngine = engine.split('/')[0] as Engine; const displayTypes: PlanSelectionWithDatabaseType[] = React.useMemo(() => { if (!dbtypes) { @@ -253,27 +170,24 @@ export const DatabaseCreate = () => { }, [dbtypes, selectedEngine]); const selectedPlan = React.useMemo(() => { - return displayTypes?.find((type) => type.id === values.type); - }, [displayTypes, values.type]); + return displayTypes?.find((displayType) => displayType.id === type); + }, [displayTypes, type]); if (flags.databasePremium && selectedPlan) { const isLimitedAvailability = getIsLimitedAvailability({ plan: selectedPlan, regionAvailabilities, - selectedRegionId: values.region, + selectedRegionId: region, }); if (isLimitedAvailability) { - setFieldValue('type', ''); + setValue('type', ''); } } const accessControlsConfiguration: AccessProps = { disabled: isRestricted, errors: ipErrorsFromAPI, - ips: values.allow_list, - onBlur: handleIPBlur, - onChange: (ips: ExtendedIP[]) => setFieldValue('allow_list', ips), variant: isVPCEnabled ? 'networking' : 'standard', }; @@ -283,59 +197,69 @@ export const DatabaseCreate = () => { return; } setSelectedTab(index); - setFieldValue('type', undefined); - setFieldValue('cluster_size', 3); + setValue('type', ''); + setValue('cluster_size', 3); }; - if (regionsLoading || !regionsData || enginesLoading || typesLoading) { - return ; - } + const onSubmit = async (values: DatabaseCreateValues) => { + if (allowList.some((ip) => ip.error)) { + return; + } - if (regionsError || typesError || enginesError) { - return ; - } + const _allowList = allowList.reduce((accum, ip) => { + if (ip.address !== '') { + return [...accum, ip.address]; + } + return accum; + }, []); - const handleNodeChange = (size: ClusterSize | undefined) => { - setFieldValue('cluster_size', size); - }; + const hasVpc = + values.private_network && + values.private_network.vpc_id && + values.private_network.subnet_id; - const handleNetworkingConfigurationChange = (vpc: null | VPC) => { - setSelectedVPC(vpc); - }; + const createPayload: CreateDatabasePayload = { + ...values, + allow_list: _allowList, + private_network: hasVpc ? values.private_network : null, + }; - const handleResetForm = (partialValues?: Partial) => { - if (partialValues) { - resetForm({ - values: { - ...values, - ...partialValues, - }, - }); - } else { - resetForm(); + // TODO (UIE-8831): Remove post VPC release, since it will always be in create payload + if (!isVPCEnabled) { + setValue('private_network', undefined); } - }; - // Custom region change handler that validates plan selection - const handleRegionChange = (field: string, value: any) => { - setFieldValue(field, value); - - // If this is a region change and a plan is selected - if (field === 'region' && flags.databasePremium && selectedPlan) { - const newRegion = regionsData?.find((region) => region.id === value); - - const isPlanAvailableInRegion = Boolean( - newRegion?.capabilities.includes( - getCapabilityFromPlanType(selectedPlan.class) - ) + try { + const response = await createDatabase(createPayload); + navigate({ + to: `/databases/$engine/$databaseId`, + params: { + engine: response.engine, + databaseId: response.id, + }, + }); + } catch (errors) { + const ipErrors = errors.filter( + (error: APIError) => error.field === 'allow_list' ); - // Clear the plan selection if plan is not available in the newly selected region - if (!isPlanAvailableInRegion) { - setFieldValue('type', ''); + if (ipErrors) { + setIPErrorsFromAPI(ipErrors); + } + + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); } } }; + if (regionsLoading || !regionsData || enginesLoading || typesLoading) { + return ; + } + + if (regionsError || typesError || enginesError) { + return ; + } + return ( <> @@ -351,111 +275,112 @@ export const DatabaseCreate = () => { }} title="Create" /> -
- {isRestricted && ( - - )} - - {createError && ( - - - + + + scrollErrorIntoViewV2(formRef) )} - - - - { - setFieldValue('type', selected); - }} - regionsData={regionsData} - selectedId={values.type} - selectedRegionID={values.region} - types={displayTypes} + ref={formRef} + > + {isRestricted && ( + - - - - { - handleNodeChange(v); - }} - selectedClusterSize={values.cluster_size} - selectedEngine={selectedEngine} - selectedPlan={selectedPlan} - selectedTab={selectedTab} - /> - - - {isVPCEnabled ? ( - - setFieldValue(field, value) - } - onNetworkingConfigurationChange={ - handleNetworkingConfigurationChange - } - privateNetworkValues={values.private_network} - resetFormFields={handleResetForm} - selectedRegionId={values.region} - /> - ) : ( - )} - - - - - - - Your database node(s) will take approximately 15-30 minutes to - provision. - - - Create Database Cluster - - - - + + {errors.root?.message && ( + + + + )} + + + + ( + + )} + /> + + + + ( + + )} + /> + + + {isVPCEnabled ? ( + + ) : ( + + )} + + + + + + + Your database node(s) will take approximately 15-30 minutes to + provision. + + + Create Database Cluster + + + + + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx index 44c34632f96..7a2c9c6ba52 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx @@ -1,12 +1,13 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { useIsDatabasesEnabled } from '../utilities'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; import type { IsDatabasesEnabled } from '../utilities'; +import type { DatabaseCreateValues } from './DatabaseCreate'; vi.mock('src/features/Databases/utilities'); @@ -21,13 +22,11 @@ describe('DatabaseCreateAccessControls', () => { } as IsDatabasesEnabled); const ips = [{ address: '' }]; - const { container, getAllByText, getAllByTestId } = renderWithTheme( - {}} - onChange={() => {}} - /> - ); + const { container, getAllByText, getAllByTestId } = + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); expect(getAllByText('Manage Access')).toHaveLength(1); expect(getAllByTestId('domain-transfer-input')).toHaveLength(1); @@ -56,13 +55,11 @@ describe('DatabaseCreateAccessControls', () => { { address: '2.2.2.2' }, { address: '3.3.3.3/128' }, ]; - const { container, getAllByText, getAllByTestId } = renderWithTheme( - {}} - onChange={() => {}} - /> - ); + const { container, getAllByText, getAllByTestId } = + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); expect(getAllByText('Manage Access')).toHaveLength(1); expect(getAllByTestId('domain-transfer-input')).toHaveLength(3); @@ -87,13 +84,11 @@ describe('DatabaseCreateAccessControls', () => { } as IsDatabasesEnabled); const ips = [{ address: '1.1.1.1/32' }]; - const { container, getAllByText, getAllByTestId } = renderWithTheme( - {}} - onChange={() => {}} - /> - ); + const { container, getAllByText, getAllByTestId } = + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); expect(getAllByText('Manage Access')).toHaveLength(1); expect(getAllByTestId('domain-transfer-input')).toHaveLength(1); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx index da30ca59413..e2dfa3b587f 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx @@ -3,75 +3,66 @@ import { Notice, Radio, RadioGroup, + styled, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; import { useState } from 'react'; import * as React from 'react'; import type { ChangeEvent } from 'react'; -import { makeStyles } from 'tss-react/mui'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; -import { ipV6FieldPlaceholder } from 'src/utilities/ipUtils'; +import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; +import { ipV6FieldPlaceholder, validateIPs } from 'src/utilities/ipUtils'; +import { ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT } from '../constants'; + +import type { DatabaseCreateValues } from './DatabaseCreate'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { Theme } from '@mui/material/styles'; import type { ExtendedIP } from 'src/utilities/ipUtils'; -const useStyles = makeStyles()((theme: Theme) => ({ - container: { - marginTop: theme.spacing(3), - maxWidth: 450, - }, - header: { - marginBottom: theme.spacing(0.5), - }, - multipleIPInput: { - marginLeft: theme.spacing(4), - }, - subHeader: { - marginTop: theme.spacing(2), - }, -})); - export type AccessOption = 'none' | 'specific'; export type AccessVariant = 'networking' | 'standard'; export interface AccessProps { disabled?: boolean; errors?: APIError[]; - ips: ExtendedIP[]; - onBlur: (ips: ExtendedIP[]) => void; - onChange: (ips: ExtendedIP[]) => void; variant?: AccessVariant; } export const DatabaseCreateAccessControls = (props: AccessProps) => { - const { - disabled = false, - errors, - ips, - onBlur, - onChange, - variant = 'standard', - } = props; - const { classes } = useStyles(); + const { disabled = false, errors, variant = 'standard' } = props; const [accessOption, setAccessOption] = useState('specific'); - const handleAccessOptionChange = (_: ChangeEvent, value: AccessOption) => { - setAccessOption(value); - if (value === 'none') { - onChange([{ address: '', error: '' }]); + const handleIPValidation = (ips: ExtendedIP[]) => { + const validatedIps = validateIPs(ips, { + allowEmptyAddress: true, + errorMessage: ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, + }); + const validatedIpsWithMasks = enforceIPMasks(validatedIps); + if (validatedIpsWithMasks.some((ip) => ip.error)) { + setValue('allow_list', validatedIpsWithMasks); + } else { + setValue( + 'allow_list', + validatedIpsWithMasks.map((ip) => { + delete ip.error; + return { + ...ip, + }; + }) + ); } }; + const { control, setValue } = useFormContext(); + const ips = useWatch({ control, name: 'allow_list' }); + return ( - - + + Manage Access @@ -82,12 +73,11 @@ export const DatabaseCreateAccessControls = (props: AccessProps) => { . - + (Note: You can modify access controls after your database cluster is active.) - - + {errors && errors.map((apiError: APIError) => ( { variant="error" /> ))} - - } - data-qa-dbaas-radio="Specific" - disabled={disabled} - label="Specific Access (recommended)" - value="specific" - /> - 1 ? 'Add Another IP' : 'Add an IP'} - className={classes.multipleIPInput} - disabled={accessOption === 'none' || disabled} - ips={ips} - onBlur={onBlur} - onChange={onChange} - placeholder={ipV6FieldPlaceholder} - title="Allowed IP Addresses or Ranges" - /> - } - data-qa-dbaas-radio="None" - disabled={disabled} - label="No Access (Deny connections from all IP addresses)" - value="none" - /> - - - + ( + { + setAccessOption(value); + if (value === 'none') { + field.onChange([{ address: '', error: '' }]); + } + }} + value={accessOption} + > + } + data-qa-dbaas-radio="Specific" + disabled={disabled} + label="Specific Access (recommended)" + value="specific" + /> + 1 ? 'Add Another IP' : 'Add an IP'} + disabled={accessOption === 'none' || disabled} + ips={ips} + onBlur={() => handleIPValidation(ips)} + onChange={field.onChange} + placeholder={ipV6FieldPlaceholder} + title="Allowed IP Addresses or Ranges" + /> + } + data-qa-dbaas-radio="None" + disabled={disabled} + label="No Access (Deny connections from all IP addresses)" + value="none" + /> + + )} + /> + + ); }; + +const StyledMultipleIPInput = styled(MultipleIPInput, { + label: 'StyledMultipleIPInput', +})(({ theme }) => ({ + marginLeft: theme.spacingFunction(32), +})); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx index d3ab82b5cbb..1f0a42b1516 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx @@ -3,10 +3,11 @@ import { screen } from '@testing-library/react'; import * as React from 'react'; import { describe, it, vi } from 'vitest'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { DatabaseCreateNetworkingConfiguration } from './DatabaseCreateNetworkingConfiguration'; +import type { DatabaseCreateValues } from './DatabaseCreate'; import type { AccessProps } from './DatabaseCreateAccessControls'; import type { PrivateNetwork } from '@linode/api-v4'; @@ -35,9 +36,6 @@ describe('DatabaseCreateNetworkingConfiguration', () => { const mockAccessControlConfig: AccessProps = { disabled: false, errors: [], - ips: [], - onBlur: vi.fn(), - onChange: vi.fn(), variant: 'networking', }; @@ -57,6 +55,8 @@ describe('DatabaseCreateNetworkingConfiguration', () => { selectedRegionId: 'us-east', }; + const ips = [{ address: '' }]; + beforeEach(() => { vi.resetAllMocks(); queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); @@ -67,7 +67,10 @@ describe('DatabaseCreateNetworkingConfiguration', () => { }); it('renders the networking configuration heading and description', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); const ConfigureNetworkingLabel = screen.getByText('Configure Networking', { exact: true, }); @@ -82,7 +85,10 @@ describe('DatabaseCreateNetworkingConfiguration', () => { }); it('renders DatabaseCreateAccessControls and DatabaseVPCSelector', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); const vpcSelector = screen.getByTestId('database-vpc-selector'); expect(vpcSelector).toBeInTheDocument(); const manageAccessLabel = screen.getByText('Manage Access'); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx index fd6542314e1..30d03f0a799 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx @@ -4,34 +4,19 @@ import * as React from 'react'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; import { DatabaseVPCSelector } from './DatabaseVPCSelector'; -import type { DatabaseCreateValues } from './DatabaseClusterData'; import type { AccessProps } from './DatabaseCreateAccessControls'; -import type { PrivateNetwork, VPC } from '@linode/api-v4'; +import type { VPC } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -import type { FormikErrors } from 'formik'; interface NetworkingConfigurationProps { accessControlsConfiguration: AccessProps; - errors: FormikErrors; - onChange: (field: string, value: boolean | null | number) => void; - onNetworkingConfigurationChange: (vpcSelected: null | VPC) => void; - privateNetworkValues: PrivateNetwork; - resetFormFields: (partialValues?: Partial) => void; - selectedRegionId: string; + onChange: (selectedVPC: null | VPC) => void; } export const DatabaseCreateNetworkingConfiguration = ( props: NetworkingConfigurationProps ) => { - const { - accessControlsConfiguration, - errors, - onNetworkingConfigurationChange, - onChange, - selectedRegionId, - resetFormFields, - privateNetworkValues, - } = props; + const { accessControlsConfiguration, onChange } = props; return ( <> @@ -45,15 +30,7 @@ export const DatabaseCreateNetworkingConfiguration = ( - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx index 4385caa656d..4136955e12d 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx @@ -1,21 +1,16 @@ +import { useDatabaseEnginesQuery } from '@linode/queries'; import { Autocomplete, Box, InputAdornment } from '@linode/ui'; import Grid from '@mui/material/Grid'; import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { getEngineOptions } from 'src/features/Databases/DatabaseCreate/utilities'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import type { DatabaseEngine } from '@linode/api-v4'; +import type { DatabaseCreateValues } from './DatabaseCreate'; -interface Props { - engines: DatabaseEngine[] | undefined; - errorText: string | undefined; - onChange: (filed: string, value: any) => void; - value: string; -} - -export const DatabaseEngineSelect = (props: Props) => { - const { engines, errorText, onChange, value } = props; +export const DatabaseEngineSelect = () => { + const { data: engines } = useDatabaseEnginesQuery(true); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_databases', }); @@ -27,65 +22,73 @@ export const DatabaseEngineSelect = (props: Props) => { return getEngineOptions(engines); }, [engines]); + const { control } = useFormContext(); + + const engineValue = useWatch({ control, name: 'engine' }); const selectedEngine = React.useMemo(() => { - return engineOptions.find((val) => val.value === value); - }, [value, engineOptions]); + return engineOptions.find((val) => val.value === engineValue); + }, [engineValue, engineOptions]); return ( - { - if (option.engine.match(/mysql/i)) { - return 'MySQL'; - } - if (option.engine.match(/postgresql/i)) { - return 'PostgreSQL'; - } - return 'Other'; - }} - isOptionEqualToValue={(option, value) => option.value === value.value} - label="Database Engine" - onChange={(_, selected) => { - onChange('engine', selected.value); - }} - options={engineOptions ?? []} - placeholder="Select a Database Engine" - renderOption={(props, option) => { - const { key, ...rest } = props; - return ( -
  • - - - {option.flag} - - {option.label} - -
  • - ); - }} - textFieldProps={{ - InputProps: { - startAdornment: ( - - - {selectedEngine?.flag} - - - ), - }, - }} - value={selectedEngine} + ( + { + if (option.engine.match(/mysql/i)) { + return 'MySQL'; + } + if (option.engine.match(/postgresql/i)) { + return 'PostgreSQL'; + } + return 'Other'; + }} + label="Database Engine" + onChange={(_, selected) => { + field.onChange(selected.value); + }} + options={engineOptions ?? []} + placeholder="Select a Database Engine" + renderOption={(props, option) => { + const { key, ...rest } = props; + return ( +
  • + + + {option.flag} + + {option.label} + +
  • + ); + }} + textFieldProps={{ + InputProps: { + startAdornment: ( + + + {selectedEngine?.flag} + + + ), + }, + }} + value={selectedEngine} + /> + )} /> ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx index 731d6d3d76e..1ca6bade784 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx @@ -5,10 +5,9 @@ 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 { DatabaseVPCSelector } from './DatabaseVPCSelector'; - import type { PrivateNetwork } from '@linode/api-v4'; // Hoist query mocks diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx index 0df9bd13875..843d55aefdf 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx @@ -6,10 +6,10 @@ import { Checkbox, FormHelperText, Notice, - TooltipIcon, Typography, } from '@linode/ui'; import * as React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; @@ -17,35 +17,25 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../constants'; -import type { DatabaseCreateValues } from './DatabaseClusterData'; -import type { PrivateNetwork, VPC } from '@linode/api-v4'; +import type { DatabaseCreateValues } from './DatabaseCreate'; +import type { VPC } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -import type { FormikErrors } from 'formik'; 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; + onChange: (selectedVPC: null | VPC) => void; } export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { - const { - errors, - mode, - onConfigurationChange, - onChange, - selectedRegionId, - resetFormFields, - privateNetworkValues, - } = props; - + const { onChange } = props; const flags = useFlags(); - const isCreate = mode === 'create'; - const { data: selectedRegion } = useRegionQuery(selectedRegionId); + const { control, setValue } = useFormContext(); + + const [region, vpcId, subnetId] = useWatch({ + control, + name: ['region', 'private_network.vpc_id', 'private_network.subnet_id'], + }); + + const { data: selectedRegion } = useRegionQuery(region); const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); const { @@ -54,77 +44,27 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { isLoading, } = useAllVPCsQuery({ enabled: regionSupportsVPCs, - filter: { region: selectedRegionId }, + filter: { region }, }); const vpcErrorMessage = vpcsError && getAPIErrorOrDefault(vpcsError, 'Unable to load VPCs')[0].reason; - const selectedVPC = vpcs?.find( - (vpc) => vpc.id === privateNetworkValues.vpc_id - ); + const selectedVPC = vpcs?.find((vpc) => vpc.id === vpcId); const selectedSubnet = selectedVPC?.subnets.find( - (subnet) => subnet.id === privateNetworkValues.subnet_id + (subnet) => subnet.id === subnetId ); - 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 + const vpcHelperTextCopy = !region ? '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 ( <> { 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} - /> - + ( + { + setValue('private_network.subnet_id', null); // Always reset subnet selection when VPC changes + if (!value) { + setValue('private_network.public_access', false); + } + onChange(value ?? null); // Update VPC in DatabaseCreate.tsx + field.onChange(value?.id ?? null); + }} + options={vpcs ?? []} + placeholder="Select a VPC" + sx={{ width: '354px' }} + textFieldProps={{ + tooltipText: + 'A cluster may be assigned only to a VPC in the same region', + }} + 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} + ( + `${subnet.label} (${subnet.ipv4})`} + label="Subnet" + onChange={(e, value) => { + field.onChange(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.' - } + ( + <> + { + field.onChange(value ?? null); + }} + text={'Enable public access'} + toolTipText={ + 'Adds a public endpoint to the database in addition to the private VPC endpoint.' + } + /> + {fieldState.error?.message && ( + + {fieldState.error?.message} + + )} + + )} /> - {errors?.private_network?.public_access && ( - - {errors?.private_network?.public_access} - - )} ) : ( - accessNotice + ({ + 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/DatabaseManageNetworkingDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx index 127882110f1..bd5c235a3fa 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx @@ -6,7 +6,7 @@ import { useFormik } from 'formik'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; -import { DatabaseVPCSelector } from '../../DatabaseCreate/DatabaseVPCSelector'; +import { DatabaseVPCSelector } from './DatabaseVPCSelector'; import type { Database, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx new file mode 100644 index 00000000000..6f5c7b7b220 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx @@ -0,0 +1,243 @@ +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 + )} + + ); +}; diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 44b4f52e77e..6e1dc16382a 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -14,8 +14,6 @@ export const createDatabaseSchema = object({ cluster_size: number() .oneOf([1, 2, 3], 'Nodes are required') .required('Nodes are required'), - replication_type: string().notRequired().nullable(), // TODO (UIE-8214) remove POST GA - replication_commit_type: string().notRequired().nullable(), // TODO (UIE-8214) remove POST GA }); export const getDynamicDatabaseSchema = (isVPCSelected: boolean) => { From a65490db29660501800a39a906f2b3bf3ad2c3ba Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 20 Oct 2025 11:33:04 -0400 Subject: [PATCH 04/39] tech-story: [M3-10569] - Upgrade Cypress from v14.3.0 to v15.4.0 (#12824) * Upgrade Cypress from v14.3.0 to v15.4.0 * Add changeset --- .../pr-12824-tech-stories-1760646228493.md | 5 + packages/manager/Dockerfile | 2 +- packages/manager/package.json | 8 +- pnpm-lock.yaml | 256 +++++++++--------- 4 files changed, 139 insertions(+), 132 deletions(-) create mode 100644 packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md diff --git a/packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md b/packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md new file mode 100644 index 00000000000..07b26be9338 --- /dev/null +++ b/packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Upgrade Cypress to v15.4.0 ([#12824](https://github.com/linode/manager/pull/12824)) diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index 7f65e774d78..ae55b12944e 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -6,7 +6,7 @@ ARG IMAGE_REGISTRY=docker.io ARG NODE_VERSION=22.19.0 # Cypress version. -ARG CYPRESS_VERSION=14.3.0 +ARG CYPRESS_VERSION=15.4.0 # Node.js base image for Cloud Manager CI tasks. # diff --git a/packages/manager/package.json b/packages/manager/package.json index 2f61df3232c..00acef29eb7 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -128,7 +128,7 @@ "@storybook/addon-docs": "^9.0.12", "@storybook/react-vite": "^9.0.12", "@swc/core": "^1.10.9", - "@testing-library/cypress": "^10.0.3", + "@testing-library/cypress": "^10.1.0", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", @@ -161,14 +161,14 @@ "chai-string": "^1.5.0", "concurrently": "^9.1.0", "css-mediaquery": "^0.1.2", - "cypress": "14.3.0", - "cypress-axe": "^1.6.0", + "cypress": "15.4.0", + "cypress-axe": "^1.7.0", "cypress-file-upload": "^5.0.8", "cypress-mochawesome-reporter": "^3.8.2", "cypress-multi-reporters": "^2.0.5", "cypress-on-fix": "^1.1.0", "cypress-real-events": "^1.14.0", - "cypress-vite": "^1.6.0", + "cypress-vite": "^1.7.0", "dotenv": "^16.0.3", "factory.ts": "^0.5.1", "glob": "^10.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba7e30eb0e..aa732be0033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -349,7 +349,7 @@ importers: devDependencies: '@4tw/cypress-drag-drop': specifier: ^2.3.0 - version: 2.3.0(cypress@14.3.0) + version: 2.3.0(cypress@15.4.0) '@storybook/addon-a11y': specifier: ^9.0.12 version: 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) @@ -363,8 +363,8 @@ importers: specifier: ^1.10.9 version: 1.10.11 '@testing-library/cypress': - specifier: ^10.0.3 - version: 10.0.3(cypress@14.3.0) + specifier: ^10.1.0 + version: 10.1.0(cypress@15.4.0) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -462,17 +462,17 @@ importers: specifier: ^0.1.2 version: 0.1.2 cypress: - specifier: 14.3.0 - version: 14.3.0 + specifier: 15.4.0 + version: 15.4.0 cypress-axe: - specifier: ^1.6.0 - version: 1.6.0(axe-core@4.10.2)(cypress@14.3.0) + specifier: ^1.7.0 + version: 1.7.0(axe-core@4.10.2)(cypress@15.4.0) cypress-file-upload: specifier: ^5.0.8 - version: 5.0.8(cypress@14.3.0) + version: 5.0.8(cypress@15.4.0) cypress-mochawesome-reporter: specifier: ^3.8.2 - version: 3.8.2(cypress@14.3.0)(mocha@10.8.2) + version: 3.8.2(cypress@15.4.0)(mocha@10.8.2) cypress-multi-reporters: specifier: ^2.0.5 version: 2.0.5(mocha@10.8.2) @@ -481,10 +481,10 @@ importers: version: 1.1.0 cypress-real-events: specifier: ^1.14.0 - version: 1.14.0(cypress@14.3.0) + version: 1.14.0(cypress@15.4.0) cypress-vite: - specifier: ^1.6.0 - version: 1.6.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^1.7.0 + version: 1.8.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) dotenv: specifier: ^16.0.3 version: 16.4.5 @@ -976,12 +976,8 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - - '@cypress/request@3.0.8': - resolution: {integrity: sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==} + '@cypress/request@3.0.9': + resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==} engines: {node: '>= 6'} '@cypress/xvfb@1.2.4': @@ -2440,11 +2436,11 @@ packages: '@tanstack/store@0.7.0': resolution: {integrity: sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==} - '@testing-library/cypress@10.0.3': - resolution: {integrity: sha512-TeZJMCNtiS59cPWalra7LgADuufO5FtbqQBYxuAgdX6ZFAR2D9CtQwAG8VbgvFcchW3K414va/+7P4OkQ80UVg==} + '@testing-library/cypress@10.1.0': + resolution: {integrity: sha512-tNkNtYRqPQh71xXKuMizr146zlellawUfDth7A/urYU4J66g0VGZ063YsS0gqS79Z58u1G/uo9UxN05qvKXMag==} engines: {node: '>=12', npm: '>=6'} peerDependencies: - cypress: ^12.0.0 || ^13.0.0 || ^14.0.0 + cypress: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} @@ -2672,6 +2668,9 @@ packages: '@types/throttle-debounce@1.1.1': resolution: {integrity: sha512-VhX9p0l8p3TS27XU+CnDfhdnzW7HpVgtKiYDh/lfucSAz8s9uTt0q4aPwcYIr+q+3/NghlU3smXBW6ItvfJKYQ==} + '@types/tmp@0.2.6': + resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -3008,9 +3007,6 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3214,10 +3210,6 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} - check-more-types@2.24.0: - resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} - engines: {node: '>= 0.8.0'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -3242,8 +3234,8 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + cli-table3@0.6.1: + resolution: {integrity: sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==} engines: {node: 10.* || >= 12.*} cli-truncate@2.1.0: @@ -3296,6 +3288,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3406,12 +3402,12 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - cypress-axe@1.6.0: - resolution: {integrity: sha512-C/ij50G8eebBrl/WsGT7E+T/SFyIsRZ3Epx9cRTLrPL9Y1GcxlQGFoAVdtSFWRrHSCWXq9HC6iJQMaI89O9yvQ==} + cypress-axe@1.7.0: + resolution: {integrity: sha512-zzJpvAAjauEB3GZl0KYXb8i3w6MztWAt2WM3czYTFyNVC30alDmqCm9E7GwZ4bgkldZJlmHakaVEyu73R5St4w==} engines: {node: '>=10'} peerDependencies: axe-core: ^3 || ^4 - cypress: ^10 || ^11 || ^12 || ^13 || ^14 + cypress: ^10 || ^11 || ^12 || ^13 || ^14 || ^15 cypress-file-upload@5.0.8: resolution: {integrity: sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==} @@ -3440,14 +3436,14 @@ packages: peerDependencies: cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x || ^13.x || ^14.x - cypress-vite@1.6.0: - resolution: {integrity: sha512-6oZPDvHgLEZjuFgoejtRuyph369zbVn7fjh4hzhMar3XvKT5YhTEoA+KixksMuxNEaLn9uqA4HJVz6l7BybwBQ==} + cypress-vite@1.8.0: + resolution: {integrity: sha512-rPkIpDzCIo+upsDkFa/NlrnzVumuQ45UcwL7a2k/n8WFIwsW8QYuQaWU2JiIKExP/LNQew3H3Hbs/bp26xC0Fw==} peerDependencies: - vite: ^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: ^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - cypress@14.3.0: - resolution: {integrity: sha512-rRfPl9Z0/CczuYybBEoLbDVuT1OGkhYaJ0+urRCshgiDRz6QnoA0KQIQnPx7MJ3zy+VCsbUU1pV74n+6cbJEdg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + cypress@15.4.0: + resolution: {integrity: sha512-+GC/Y/LXAcaMCzfuM7vRx5okRmonceZbr0ORUAoOrZt/5n2eGK8yh04bok1bWSjZ32wRHrZESqkswQ6biArN5w==} + engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} hasBin: true d3-array@3.2.4: @@ -4109,9 +4105,6 @@ packages: get-tsconfig@4.8.1: resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} - getos@3.2.1: - resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} - getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} @@ -4201,6 +4194,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4652,10 +4649,6 @@ packages: react: ^16.6.3 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.4 || ^17.0.0 || ^18.0.0 - lazy-ass@1.6.0: - resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} - engines: {node: '> 0.8'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -5851,6 +5844,12 @@ packages: resolution: {integrity: sha512-fWZqNBZNNFp/7mTUy1fSsydhKsAKJ+u90Nk7kOK5Gcq9vObaqLBLjWFDBkyVU9Vvc6Y71VbOevMuGhqv02bT+Q==} engines: {node: ^14.18.0 || >=16.0.0} + systeminformation@5.27.7: + resolution: {integrity: sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==} + engines: {node: '>=8.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + tcomb-validation@3.4.1: resolution: {integrity: sha512-urVVMQOma4RXwiVCa2nM2eqrAomHROHvWPuj6UkDGz/eb5kcy0x6P0dVt6kzpUZtYMNoAqJLWmz1BPtxrtjtrA==} @@ -5928,8 +5927,8 @@ packages: resolution: {integrity: sha512-rv8LUyez4Ygkopqn+M6OLItAOT9FF3REpPQDkdMx5ix8w4qkuE7Vo2o/vw1nxKQYmJDV8JpAMJQr1b+lTKf0FA==} hasBin: true - tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} to-regex-range@5.0.1: @@ -6042,6 +6041,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} @@ -6450,9 +6453,9 @@ packages: snapshots: - '@4tw/cypress-drag-drop@2.3.0(cypress@14.3.0)': + '@4tw/cypress-drag-drop@2.3.0(cypress@15.4.0)': dependencies: - cypress: 14.3.0 + cypress: 15.4.0 '@adobe/css-tools@4.4.1': {} @@ -6558,7 +6561,7 @@ snapshots: '@babel/traverse': 7.25.9 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 7.6.3 @@ -6629,7 +6632,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6678,10 +6681,7 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 - '@colors/colors@1.5.0': - optional: true - - '@cypress/request@3.0.8': + '@cypress/request@3.0.9': dependencies: aws-sign2: 0.7.0 aws4: 1.13.2 @@ -6985,7 +6985,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -6999,7 +6999,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -7251,7 +7251,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -7326,7 +7326,7 @@ snapshots: '@mui/private-theming@7.1.0(@types/react@19.1.6)(react@19.1.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@mui/utils': 7.1.0(@types/react@19.1.6)(react@19.1.0) prop-types: 15.8.1 react: 19.1.0 @@ -7335,7 +7335,7 @@ snapshots: '@mui/styled-engine@7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(react@19.1.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@emotion/cache': 11.13.5 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -7348,7 +7348,7 @@ snapshots: '@mui/system@7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@mui/private-theming': 7.1.0(@types/react@19.1.6)(react@19.1.0) '@mui/styled-engine': 7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(react@19.1.0) '@mui/types': 7.4.2(@types/react@19.1.6) @@ -7364,7 +7364,7 @@ snapshots: '@mui/types@7.4.2(@types/react@19.1.6)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 optionalDependencies: '@types/react': 19.1.6 @@ -7959,11 +7959,11 @@ snapshots: '@tanstack/store@0.7.0': {} - '@testing-library/cypress@10.0.3(cypress@14.3.0)': + '@testing-library/cypress@10.1.0(cypress@15.4.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@testing-library/dom': 10.4.0 - cypress: 14.3.0 + cypress: 15.4.0 '@testing-library/dom@10.4.0': dependencies: @@ -8202,6 +8202,8 @@ snapshots: '@types/throttle-debounce@1.1.1': {} + '@types/tmp@0.2.6': {} + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': {} @@ -8259,7 +8261,7 @@ snapshots: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 eslint: 9.31.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: @@ -8271,7 +8273,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.38.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 eslint: 9.31.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: @@ -8281,7 +8283,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.7.3) '@typescript-eslint/types': 8.38.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -8304,7 +8306,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.7.3) '@typescript-eslint/utils': 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 eslint: 9.31.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 @@ -8316,7 +8318,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.7.3) '@typescript-eslint/utils': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 eslint: 9.31.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 @@ -8331,7 +8333,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8347,7 +8349,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.7.3) '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8402,7 +8404,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -8511,7 +8513,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -8670,8 +8672,6 @@ snapshots: astral-regex@2.0.0: {} - async@3.2.6: {} - asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -8706,7 +8706,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 cosmiconfig: 7.1.0 resolve: 1.22.8 @@ -8815,7 +8815,7 @@ snapshots: canvg@3.0.11: dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@types/raf': 3.4.3 core-js: 3.39.0 raf: 3.4.1 @@ -8881,8 +8881,6 @@ snapshots: check-error@2.1.1: {} - check-more-types@2.24.0: {} - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -8911,11 +8909,11 @@ snapshots: dependencies: restore-cursor: 5.1.0 - cli-table3@0.6.5: + cli-table3@0.6.1: dependencies: string-width: 4.2.3 optionalDependencies: - '@colors/colors': 1.5.0 + colors: 1.4.0 cli-truncate@2.1.0: dependencies: @@ -8971,6 +8969,9 @@ snapshots: colorette@2.0.20: {} + colors@1.4.0: + optional: true + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -9067,19 +9068,19 @@ snapshots: csstype@3.1.3: {} - cypress-axe@1.6.0(axe-core@4.10.2)(cypress@14.3.0): + cypress-axe@1.7.0(axe-core@4.10.2)(cypress@15.4.0): dependencies: axe-core: 4.10.2 - cypress: 14.3.0 + cypress: 15.4.0 - cypress-file-upload@5.0.8(cypress@14.3.0): + cypress-file-upload@5.0.8(cypress@15.4.0): dependencies: - cypress: 14.3.0 + cypress: 15.4.0 - cypress-mochawesome-reporter@3.8.2(cypress@14.3.0)(mocha@10.8.2): + cypress-mochawesome-reporter@3.8.2(cypress@15.4.0)(mocha@10.8.2): dependencies: commander: 10.0.1 - cypress: 14.3.0 + cypress: 15.4.0 fs-extra: 10.1.0 mochawesome: 7.1.3(mocha@10.8.2) mochawesome-merge: 4.4.1 @@ -9089,7 +9090,7 @@ snapshots: cypress-multi-reporters@2.0.5(mocha@10.8.2): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 lodash: 4.17.21 mocha: 10.8.2 semver: 7.6.3 @@ -9098,42 +9099,42 @@ snapshots: cypress-on-fix@1.1.0: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color - cypress-real-events@1.14.0(cypress@14.3.0): + cypress-real-events@1.14.0(cypress@15.4.0): dependencies: - cypress: 14.3.0 + cypress: 15.4.0 - cypress-vite@1.6.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + cypress-vite@1.8.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: chokidar: 3.6.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color - cypress@14.3.0: + cypress@15.4.0: dependencies: - '@cypress/request': 3.0.8 + '@cypress/request': 3.0.9 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) '@types/sinonjs__fake-timers': 8.1.1 '@types/sizzle': 2.3.9 + '@types/tmp': 0.2.6 arch: 2.2.0 blob-util: 2.0.2 bluebird: 3.7.2 buffer: 5.7.1 cachedir: 2.4.0 chalk: 4.1.2 - check-more-types: 2.24.0 ci-info: 4.1.0 cli-cursor: 3.1.0 - cli-table3: 0.6.5 + cli-table3: 0.6.1 commander: 6.2.1 common-tags: 1.8.2 dayjs: 1.11.13 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) enquirer: 2.4.1 eventemitter2: 6.4.7 execa: 4.1.0 @@ -9141,9 +9142,8 @@ snapshots: extract-zip: 2.0.1(supports-color@8.1.1) figures: 3.2.0 fs-extra: 9.1.0 - getos: 3.2.1 + hasha: 5.2.2 is-installed-globally: 0.4.0 - lazy-ass: 1.6.0 listr2: 3.14.0(enquirer@2.4.1) lodash: 4.17.21 log-symbols: 4.1.0 @@ -9155,7 +9155,8 @@ snapshots: request-progress: 3.0.0 semver: 7.6.3 supports-color: 8.1.1 - tmp: 0.2.3 + systeminformation: 5.27.7 + tmp: 0.2.5 tree-kill: 1.2.2 untildify: 4.0.0 yauzl: 2.10.0 @@ -9237,11 +9238,9 @@ snapshots: optionalDependencies: supports-color: 8.1.1 - debug@4.4.0(supports-color@8.1.1): + debug@4.4.0: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 debug@4.4.1(supports-color@8.1.1): dependencies: @@ -9301,7 +9300,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 csstype: 3.1.3 dompurify@3.2.4: @@ -9462,7 +9461,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.3): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 esbuild: 0.25.3 transitivePeerDependencies: - supports-color @@ -9665,7 +9664,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -9755,7 +9754,7 @@ snapshots: extract-zip@2.0.1(supports-color@8.1.1): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -9979,10 +9978,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - getos@3.2.1: - dependencies: - async: 3.2.6 - getpass@0.1.7: dependencies: assert-plus: 1.0.0 @@ -10068,6 +10063,11 @@ snapshots: dependencies: has-symbols: 1.1.0 + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -10126,7 +10126,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -10139,7 +10139,7 @@ snapshots: https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -10377,7 +10377,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -10535,8 +10535,6 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - lazy-ass@1.6.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -10556,7 +10554,7 @@ snapshots: dependencies: chalk: 5.4.1 commander: 13.1.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.2.5 @@ -10574,7 +10572,7 @@ snapshots: log-update: 4.0.0 p-map: 4.0.0 rfdc: 1.4.1 - rxjs: 7.8.1 + rxjs: 7.8.2 through: 2.3.8 wrap-ansi: 7.0.0 optionalDependencies: @@ -10794,7 +10792,7 @@ snapshots: mocha-junit-reporter@2.2.1(mocha@10.8.2): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 md5: 2.3.0 mkdirp: 3.0.1 mocha: 10.8.2 @@ -11290,7 +11288,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -11662,7 +11660,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -11895,6 +11893,8 @@ snapshots: '@pkgr/core': 0.2.0 tslib: 2.8.1 + systeminformation@5.27.7: {} + tcomb-validation@3.4.1: dependencies: tcomb: 3.2.29 @@ -11966,7 +11966,7 @@ snapshots: dependencies: tldts-core: 6.1.61 - tmp@0.2.3: {} + tmp@0.2.5: {} to-regex-range@5.0.1: dependencies: @@ -12035,7 +12035,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 esbuild: 0.25.3 joycon: 3.1.1 picocolors: 1.1.1 @@ -12076,6 +12076,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@0.8.1: {} + type-fest@2.19.0: {} type-fest@4.27.0: {} @@ -12307,7 +12309,7 @@ snapshots: '@vitest/spy': 3.1.2 '@vitest/utils': 3.1.2 chai: 5.2.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 From bb3bfb829f5382262764d772d9eec4d01a4e36cf Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Tue, 21 Oct 2025 11:41:10 +0200 Subject: [PATCH 05/39] feat: [UIE-9356] - IAM Parent/Child: Child Account - hide tabs (#12982) * feat: [UIE-9356] - IAM Parent/Child: Child Account - hide tabs * Added changeset: IAM Parent/Child: hide User details tab for delegate user and add a badge * fix route * gate from route --------- Co-authored-by: Alban Bailly --- .../pr-12982-added-1760358578837.md | 5 ++ .../features/IAM/Users/UserDetailsLanding.tsx | 21 ++++++- .../features/IAM/Users/UsersTable/UserRow.tsx | 9 ++- packages/manager/src/routes/IAM/index.ts | 57 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-12982-added-1760358578837.md diff --git a/packages/manager/.changeset/pr-12982-added-1760358578837.md b/packages/manager/.changeset/pr-12982-added-1760358578837.md new file mode 100644 index 00000000000..ccb8fb15a07 --- /dev/null +++ b/packages/manager/.changeset/pr-12982-added-1760358578837.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM Parent/Child: hide User details tab for delegate user and add a badge ([#12982](https://github.com/linode/manager/pull/12982)) diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 23ed63b0a9f..3fcd1a070bb 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -1,4 +1,5 @@ -import { Outlet, useParams } from '@tanstack/react-router'; +import { Chip, styled } from '@linode/ui'; +import { Outlet, useLoaderData, useParams } from '@tanstack/react-router'; import React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -20,11 +21,15 @@ export const UserDetailsLanding = () => { const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isParentAccount } = useDelegationRole(); + const { isDelegateUserForChildAccount } = useLoaderData({ + from: '/iam/users/$username', + }); const { tabs, tabIndex, handleTabChange } = useTabs([ { to: `/iam/users/$username/details`, title: 'User Details', + hide: isDelegateUserForChildAccount, }, { to: `/iam/users/$username/roles`, @@ -56,6 +61,9 @@ export const UserDetailsLanding = () => { ], labelOptions: { noCap: true, + suffixComponent: isDelegateUserForChildAccount ? ( + + ) : null, }, pathname: location.pathname, }} @@ -73,3 +81,14 @@ export const UserDetailsLanding = () => { ); }; + +const StyledChip = styled(Chip, { + label: 'StyledChip', +})(({ theme }) => ({ + textTransform: theme.tokens.font.Textcase.Uppercase, + marginLeft: theme.spacingFunction(4), + color: theme.tokens.component.Badge.Informative.Subtle.Text, + backgroundColor: theme.tokens.component.Badge.Informative.Subtle.Background, + font: theme.font.extrabold, + fontSize: theme.tokens.font.FontSize.Xxxs, +})); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx index 55283eaaf61..a32f449185a 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx @@ -55,7 +55,14 @@ export const UserRow = ({ onDelete, user }: Props) => { {canViewUser ? ( - + {user.username} ) : ( diff --git a/packages/manager/src/routes/IAM/index.ts b/packages/manager/src/routes/IAM/index.ts index 773180df4c8..22ef887cf77 100644 --- a/packages/manager/src/routes/IAM/index.ts +++ b/packages/manager/src/routes/IAM/index.ts @@ -1,3 +1,5 @@ +import { accountQueries, profileQueries } from '@linode/queries'; +import { queryOptions } from '@tanstack/react-query'; import { createRoute, redirect } from '@tanstack/react-router'; import { checkIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; @@ -157,6 +159,61 @@ const iamDelegationsCatchAllRoute = createRoute({ const iamUserNameRoute = createRoute({ getParentRoute: () => iamRoute, path: '/users/$username', + loader: async ({ context, params, location }) => { + const isIAMEnabled = await checkIAMEnabled( + context.queryClient, + context.flags + ); + const { username } = params; + const isIAMDelegationEnabled = context.flags?.iamDelegation?.enabled; + + if (isIAMEnabled && username && isIAMDelegationEnabled) { + const profile = await context.queryClient.ensureQueryData( + queryOptions(profileQueries.profile()) + ); + + const isChildAccount = profile?.user_type === 'child'; + + if (!profile.restricted && isChildAccount) { + const user = await context.queryClient.ensureQueryData( + queryOptions(accountQueries.users._ctx.user(username)) + ); + + const isChildAccount = profile?.user_type === 'child'; + const isDelegateUser = user?.user_type === 'delegate'; + + // Determine if the current account is a child account with isIAMDelegationEnabled enabled + // If so, we need to hide 'View User Details' and 'Account Delegations' tabs for delegate users + const isDelegateUserForChildAccount = isChildAccount && isDelegateUser; + + // There is no detail view for delegate users in a child account + if ( + isDelegateUserForChildAccount && + location.pathname.endsWith('/details') + ) { + throw redirect({ + to: '/iam/users/$username/roles', + params: { username }, + replace: true, + }); + } + + // We may not need to return all this data tho I can't think of a reason why we wouldn't, + // considering several views served by this route rely on it. + return { + user, + profile, + isIAMDelegationEnabled, + isDelegateUserForChildAccount, + }; + } + } + + return { + isIAMEnabled, + username, + }; + }, }).lazy(() => import('src/features/IAM/Users/userDetailsLandingLazyRoute').then( (m) => m.userDetailsLandingLazyRoute From ac5aa4480f6c5706aaeb0e6c291fdd176740642f Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:31:27 +0200 Subject: [PATCH 06/39] feat: [UIE-9270] - IAM Delegation: Account Delegations Drawer (#12970) * feat: [UIE-9270] - IAM Delegation: Account Delegation Drawer * feat: [UIE-9270] - fix autocomplete, unit test * cleanup * Added changeset: IAM: Account Delegations Drawer * feat: [UIE-9270] - IAM Delegation: review fix * data handling fixes --------- Co-authored-by: Alban Bailly --- packages/api-v4/src/iam/delegation.ts | 4 +- packages/api-v4/src/iam/delegation.types.ts | 2 +- ...r-12970-upcoming-features-1760351502737.md | 5 + .../AccountDelegationsTableRow.tsx | 15 +- .../UpdateDelegationsDrawer.test.tsx | 144 +++++++++++++ .../Delegations/UpdateDelegationsDrawer.tsx | 191 ++++++++++++++++++ .../src/features/IAM/Shared/Entities/utils.ts | 5 +- .../src/features/IAM/Shared/constants.ts | 10 +- .../mocks/presets/crud/handlers/delegation.ts | 3 +- .../src/mocks/presets/crud/handlers/users.ts | 4 +- .../manager/src/mocks/utilities/response.ts | 6 +- packages/queries/src/iam/delegation.ts | 16 +- 12 files changed, 384 insertions(+), 21 deletions(-) create mode 100644 packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md create mode 100644 packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx create mode 100644 packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts index dba6dcc0016..333546ad6e9 100644 --- a/packages/api-v4/src/iam/delegation.ts +++ b/packages/api-v4/src/iam/delegation.ts @@ -57,14 +57,14 @@ export const getChildAccountDelegates = ({ export const updateChildAccountDelegates = ({ euuid, - data, + users, }: UpdateChildAccountDelegatesParams) => Request>( setURL( `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, ), setMethod('PUT'), - setData(data), + setData(users), ); export const getMyDelegatedChildAccounts = ({ diff --git a/packages/api-v4/src/iam/delegation.types.ts b/packages/api-v4/src/iam/delegation.types.ts index 4d9006d0cbd..2cb19ea628d 100644 --- a/packages/api-v4/src/iam/delegation.types.ts +++ b/packages/api-v4/src/iam/delegation.types.ts @@ -30,6 +30,6 @@ export interface GetChildAccountDelegatesParams { } export interface UpdateChildAccountDelegatesParams { - data: string[]; euuid: string; + users: string[]; } diff --git a/packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md b/packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md new file mode 100644 index 00000000000..b9edd11c74a --- /dev/null +++ b/packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +IAM: Account Delegations Drawer ([#12970](https://github.com/linode/manager/pull/12970)) diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx index c2764b224a9..13116941df4 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx @@ -6,6 +6,7 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow/TableRow'; import { TruncatedList } from '../Shared/TruncatedList'; +import { UpdateDelegationsDrawer } from './UpdateDelegationsDrawer'; import type { ChildAccount, ChildAccountWithDelegates } from '@linode/api-v4'; @@ -16,10 +17,14 @@ interface Props { export const AccountDelegationsTableRow = ({ delegation, index }: Props) => { const theme = useTheme(); + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const handleUpdateDelegations = () => { - // Placeholder for future update delegations functionality - // This will open the Update Delegates drawer + setIsDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setIsDrawerOpen(false); }; return ( @@ -40,7 +45,6 @@ export const AccountDelegationsTableRow = ({ delegation, index }: Props) => { ( - // TODO: move to the separate component { onClick={handleUpdateDelegations} /> + ); }; diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx new file mode 100644 index 00000000000..7877411075e --- /dev/null +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx @@ -0,0 +1,144 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { DELEGATION_VALIDATION_ERROR } from '../Shared/constants'; +import { UpdateDelegationsDrawer } from './UpdateDelegationsDrawer'; + +import type { ChildAccountWithDelegates, User } from '@linode/api-v4'; + +beforeAll(() => mockMatchMedia()); + +const mocks = vi.hoisted(() => ({ + mockUseAccountUsers: vi.fn(), + mockUseUpdateChildAccountDelegatesQuery: vi.fn(), + mockMutateAsync: vi.fn(), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccountUsers: mocks.mockUseAccountUsers, + useUpdateChildAccountDelegatesQuery: + mocks.mockUseUpdateChildAccountDelegatesQuery, + }; +}); + +const mockUsers: User[] = [ + { + email: 'user1@example.com', + last_login: null, + password_created: null, + restricted: false, + ssh_keys: [], + tfa_enabled: false, + user_type: 'default', + username: 'user1', + verified_phone_number: null, + }, + { + email: 'user2@example.com', + last_login: null, + password_created: null, + restricted: false, + ssh_keys: [], + tfa_enabled: false, + user_type: 'default', + username: 'user2', + verified_phone_number: null, + }, +]; + +const mockChildAccountWithDelegates: ChildAccountWithDelegates = { + company: 'Test Company', + euuid: 'E1234567-89AB-CDEF-0123-456789ABCDEF', + users: ['user1'], +}; + +const defaultProps = { + delegation: mockChildAccountWithDelegates, + onClose: vi.fn(), + open: true, +}; + +describe('UpdateDelegationsDrawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.mockUseAccountUsers.mockReturnValue({ + data: { data: mockUsers }, + isLoading: false, + }); + + mocks.mockUseUpdateChildAccountDelegatesQuery.mockReturnValue({ + mutateAsync: mocks.mockMutateAsync, + }); + + mocks.mockMutateAsync.mockResolvedValue({}); + }); + + it('renders the drawer with current delegates', () => { + renderWithTheme(); + + const header = screen.getByRole('heading', { name: /update delegations/i }); + expect(header).toBeInTheDocument(); + const companyName = screen.getByText(/test company/i); + expect(companyName).toBeInTheDocument(); + const userName = screen.getByText(/user1/i); + expect(userName).toBeInTheDocument(); + }); + + it('allows adding a new delegate', async () => { + renderWithTheme(); + + const user = userEvent.setup(); + + const autocompleteInput = screen.getByRole('combobox'); + await user.click(autocompleteInput); + + await waitFor(async () => { + screen.getByRole('option', { name: 'user2' }); + }); + + const user2Option = screen.getByRole('option', { name: 'user2' }); + await user.click(user2Option); + + const submitButton = screen.getByRole('button', { name: /update/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(mocks.mockMutateAsync).toHaveBeenCalledWith({ + euuid: mockChildAccountWithDelegates.euuid, + users: ['user1', 'user2'], + }); + }); + }); + + it('should show error when no users are selected', async () => { + const emptyDelegation = { + ...mockChildAccountWithDelegates, + users: [], + }; + + renderWithTheme( + + ); + + // Try to submit without selecting any users + const submitButton = screen.getByRole('button', { name: 'Update' }); + await userEvent.click(submitButton); + + await waitFor(() => { + const errorElement = screen.getByText(DELEGATION_VALIDATION_ERROR); + expect(errorElement).toBeVisible(); + }); + }); +}); diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx new file mode 100644 index 00000000000..698ea7cd42e --- /dev/null +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx @@ -0,0 +1,191 @@ +import { + useAccountUsers, + useUpdateChildAccountDelegatesQuery, +} from '@linode/queries'; +import { + ActionsPanel, + Autocomplete, + Drawer, + Notice, + Typography, + useTheme, +} from '@linode/ui'; +import { enqueueSnackbar } from 'notistack'; +import React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; + +import { + DELEGATION_VALIDATION_ERROR, + INTERNAL_ERROR_NO_CHANGES_SAVED, +} from '../Shared/constants'; +import { getPlaceholder } from '../Shared/Entities/utils'; + +import type { ChildAccount, ChildAccountWithDelegates } from '@linode/api-v4'; + +interface UserOption { + label: string; + value: string; +} + +interface UpdateDelegationsFormValues { + users: UserOption[]; +} + +interface Props { + delegation: ChildAccount | ChildAccountWithDelegates | undefined; + onClose: () => void; + open: boolean; +} + +export const UpdateDelegationsDrawer = ({ + delegation, + onClose, + open, +}: Props) => { + const theme = useTheme(); + + // Get all parent accounts as options for delegation + const { data: allParentAccounts, isLoading } = useAccountUsers({ + enabled: open, + filters: { user_type: 'parent' }, + }); + + const { mutateAsync: updateDelegates } = + useUpdateChildAccountDelegatesQuery(); + + const currentUsers = React.useMemo(() => { + if (delegation && 'users' in delegation && delegation.users) { + return delegation.users; + } + return []; + }, [delegation]); + + const userOptions = React.useMemo(() => { + if (!allParentAccounts?.data) return []; + return allParentAccounts.data.map((user) => ({ + label: user.username, + value: user.username, + })); + }, [allParentAccounts]); + + const form = useForm({ + defaultValues: { + users: currentUsers.map((username) => ({ + label: username, + value: username, + })), + }, + }); + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + setError, + watch, + } = form; + + watch('users'); + + const onSubmit = async (values: UpdateDelegationsFormValues) => { + if (!delegation) return; + + const usersList = values.users.map((user) => user.value); + + try { + await updateDelegates({ + euuid: delegation.euuid, + users: usersList, + }); + enqueueSnackbar(`Delegate users updated.`, { variant: 'success' }); + handleClose(); + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { + message: INTERNAL_ERROR_NO_CHANGES_SAVED, + }); + } + } + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + return ( + + {errors.root?.message && ( + + )} + +
    + + Add or remove users who should have access to the child account. + Delegate users removed from this list will lose the role assignment + on the child account and they won’t be visible in the user list on + the child account. + + + {delegation && ( + + Update delegation for {delegation.company}: + + )} + + ( + + option.value === value.value + } + label={'Delegate Users'} + loading={isLoading} + multiple + noMarginTop + onChange={(_, newValue) => { + field.onChange(newValue || []); + }} + options={userOptions} + placeholder={getPlaceholder( + 'delegates', + field.value.length, + userOptions.length + )} + textFieldProps={{ + hideLabel: true, + }} + value={field.value} + /> + )} + rules={{ + required: DELEGATION_VALIDATION_ERROR, + }} + /> + + + +
    +
    + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/Entities/utils.ts b/packages/manager/src/features/IAM/Shared/Entities/utils.ts index 6f970b1ecde..aab965e3fd6 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/utils.ts +++ b/packages/manager/src/features/IAM/Shared/Entities/utils.ts @@ -3,6 +3,8 @@ import { groupAccountEntitiesByType } from '../utilities'; import type { EntitiesOption } from '../types'; import type { AccessType, AccountEntity, EntityType } from '@linode/api-v4'; +type PlaceholderType = 'delegates' | AccessType; + export const placeholderMap: Record = { account: 'Select Account', database: 'Select Databases', @@ -17,6 +19,7 @@ export const placeholderMap: Record = { stackscript: 'Select Stackscripts', volume: 'Select Volumes', vpc: 'Select VPCs', + delegates: 'Select Users', }; export const getCreateLinkForEntityType = (entityType: AccessType): string => { @@ -34,7 +37,7 @@ export const getCreateLinkForEntityType = (entityType: AccessType): string => { }; export const getPlaceholder = ( - type: AccessType, + type: PlaceholderType, currentValueLength: number, possibleEntitiesLength: number ): string => { diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index 6525d064472..6601da3840f 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -11,10 +11,15 @@ export const INTERNAL_ERROR_NO_CHANGES_SAVED = `Internal Error. No changes were export const LAST_ACCOUNT_ADMIN_ERROR = 'Failed to unassign the role. You need to have at least one user with the account_admin role on your account.'; -export const NO_DELEGATIONS_TEXT = 'No delegate users found.'; + export const ERROR_STATE_TEXT = 'An unexpected error occurred. Refresh the page or try again later.'; +// Delegation error messages +export const NO_DELEGATIONS_TEXT = 'No delegate users found.'; +export const DELEGATION_VALIDATION_ERROR = + 'At least one user must be selected as a delegate.'; + // Links export const IAM_DOCS_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/identity-and-access-cm'; @@ -44,6 +49,3 @@ export const ROLES_TABLE_PREFERENCE_KEY = 'roles'; export const ENTITIES_TABLE_PREFERENCE_KEY = 'entities'; export const ASSIGNED_ROLES_TABLE_PREFERENCE_KEY = 'assigned-roles'; - -export const ACCOUNT_DELEGATIONS_TABLE_PREFERENCE_KEY = - 'iam-account-delegations'; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts index 7053cccafcb..5782db4237f 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @@ -143,8 +143,7 @@ export const childAccountDelegates = (mockState: MockState) => [ StrictResponse> > => { const euuid = params.euuid as string; - const requestData = (await request.json()) as { users: string[] }; - const newUsernames = requestData?.users || []; + const newUsernames = (await request.json()) as string[]; // Get current delegations const allDelegations = await mswDB.getAll('delegations'); diff --git a/packages/manager/src/mocks/presets/crud/handlers/users.ts b/packages/manager/src/mocks/presets/crud/handlers/users.ts index 1ac9e4e44e8..eabb3eb3212 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/users.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/users.ts @@ -72,7 +72,9 @@ export const addUserToMockState = async (mockState: MockState, user: User) => { await mswDB.add('users', delegateUser, mockState); // Create child accounts - const childAccounts = childAccountFactory.buildList(4); + const childAccounts = childAccountFactory.buildList(4, { + company: `child-account-${user.username}`, + }); // Create delegations pointing to our active user (parent) for (const childAccount of childAccounts) { diff --git a/packages/manager/src/mocks/utilities/response.ts b/packages/manager/src/mocks/utilities/response.ts index 2ecfdbb7b5d..9a4d9c28edb 100644 --- a/packages/manager/src/mocks/utilities/response.ts +++ b/packages/manager/src/mocks/utilities/response.ts @@ -196,10 +196,10 @@ export const makePaginatedResponse = ({ if ( !a || !b || - !(orderBy in a) || - !(orderBy in b) || typeof a !== 'object' || - typeof b !== 'object' + typeof b !== 'object' || + !(orderBy in a) || + !(orderBy in b) ) { return 0; } diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts index d6a281357fe..b3905e70221 100644 --- a/packages/queries/src/iam/delegation.ts +++ b/packages/queries/src/iam/delegation.ts @@ -25,6 +25,7 @@ import type { Params, ResourcePage, Token, + UpdateChildAccountDelegatesParams, } from '@linode/api-v4'; import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; @@ -212,7 +213,7 @@ export const useGetChildAccountDelegatesQuery = ({ euuid, params, }: GetChildAccountDelegatesParams): UseQueryResult< - ResourcePage, + ResourcePage, APIError[] > => { return useQuery({ @@ -233,16 +234,23 @@ export const useGetChildAccountDelegatesQuery = ({ export const useUpdateChildAccountDelegatesQuery = (): UseMutationResult< ResourcePage, APIError[], - { data: string[]; euuid: string } + UpdateChildAccountDelegatesParams > => { const queryClient = useQueryClient(); return useMutation< ResourcePage, APIError[], - { data: string[]; euuid: string } + UpdateChildAccountDelegatesParams >({ - mutationFn: updateChildAccountDelegates, + mutationFn: (data) => updateChildAccountDelegates(data), onSuccess(_data, { euuid }) { + queryClient.invalidateQueries({ + queryKey: delegationQueries.childAccounts({ params: {}, users: true }) + .queryKey, + }); + queryClient.invalidateQueries({ + queryKey: delegationQueries.allChildAccounts._def, + }); // Invalidate all child account delegates queryClient.invalidateQueries({ queryKey: delegationQueries.childAccountDelegates({ euuid }).queryKey, From eebbd63aa96c635f95c531cf17552ce5d2fa40a9 Mon Sep 17 00:00:00 2001 From: shagufa-akamai Date: Tue, 21 Oct 2025 10:10:34 -0500 Subject: [PATCH 07/39] fix: [M3-10430] - Focus Indicator on MUI Toggle (#12988) * fix: [M3-10430] - Focus Indicator on MUI Toggle * Added changeset: Misaligned focus indicator on the Toggle component causing visual inconsistency when navigating via keyboard --- packages/ui/.changeset/pr-12988-fixed-1760538879992.md | 5 +++++ packages/ui/src/components/Toggle/Toggle.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 packages/ui/.changeset/pr-12988-fixed-1760538879992.md diff --git a/packages/ui/.changeset/pr-12988-fixed-1760538879992.md b/packages/ui/.changeset/pr-12988-fixed-1760538879992.md new file mode 100644 index 00000000000..04bf293b4a1 --- /dev/null +++ b/packages/ui/.changeset/pr-12988-fixed-1760538879992.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Fixed +--- + +Misaligned focus indicator on the Toggle component causing visual inconsistency when navigating via keyboard ([#12988](https://github.com/linode/manager/pull/12988)) diff --git a/packages/ui/src/components/Toggle/Toggle.tsx b/packages/ui/src/components/Toggle/Toggle.tsx index e95b44e650e..78daf559784 100644 --- a/packages/ui/src/components/Toggle/Toggle.tsx +++ b/packages/ui/src/components/Toggle/Toggle.tsx @@ -35,6 +35,7 @@ export const Toggle = (props: ToggleProps) => { sx={{ position: 'relative', display: 'inline-flex', + overflow: 'visible', }} > { height="16px" sx={{ position: 'absolute', - top: size === 'medium' ? '18px' : '15.5px', - left: size === 'medium' ? '20px' : tooltipText ? '18px' : '25px', + top: size === 'medium' ? '20px' : '15.5px', + left: size === 'medium' ? '22px' : tooltipText ? '18px' : '25px', fill: 'white', zIndex: 1, pointerEvents: 'none', @@ -76,6 +77,8 @@ export const Toggle = (props: ToggleProps) => { left: '-6px', }, }), + overflow: 'visible', + margin: '2px', ...sx, }} /> From d162aa938d4f2a7f6ad80488ba5e35d40cd9efa7 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:16:27 -0400 Subject: [PATCH 08/39] tech-story: [M3-10337] - Update Vite to v7 (#12792) * update to vite v7 * align configs * Added changeset: Update Vite to v7 * update vite to latest --------- Co-authored-by: Banks Nussman --- package.json | 4 +- .../pr-12792-tech-stories-1756824978680.md | 5 + packages/manager/cypress/vite.config.ts | 5 +- packages/manager/package.json | 10 +- packages/manager/vite.config.ts | 6 +- packages/shared/package.json | 2 +- packages/shared/vitest.config.ts | 4 +- packages/ui/package.json | 2 +- packages/ui/vitest.config.ts | 4 +- pnpm-lock.yaml | 556 +++++++++++++----- 10 files changed, 435 insertions(+), 163 deletions(-) create mode 100644 packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md diff --git a/package.json b/package.json index c2fa7452a5f..388b9dd7ee5 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "concurrently": "9.1.0", "husky": "^9.1.6", "typescript": "^5.7.3", - "vitest": "^3.1.2", - "@vitest/ui": "^3.1.2", + "vitest": "^3.2.4", + "@vitest/ui": "^3.2.4", "lint-staged": "^15.4.3", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", diff --git a/packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md b/packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md new file mode 100644 index 00000000000..c4240611d7d --- /dev/null +++ b/packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update Vite to v7 ([#12792](https://github.com/linode/manager/pull/12792)) diff --git a/packages/manager/cypress/vite.config.ts b/packages/manager/cypress/vite.config.ts index b600661f33b..136bc9292cf 100644 --- a/packages/manager/cypress/vite.config.ts +++ b/packages/manager/cypress/vite.config.ts @@ -7,7 +7,10 @@ import svgr from 'vite-plugin-svgr'; const DIRNAME = new URL('.', import.meta.url).pathname; export default defineConfig({ - plugins: [react(), svgr({ exportAsDefault: true })], + plugins: [ + react(), + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + ], build: { rollupOptions: { // Suppress "SOURCEMAP_ERROR" warnings. diff --git a/packages/manager/package.json b/packages/manager/package.json index 00acef29eb7..3b598be3372 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -154,8 +154,8 @@ "@types/redux-mock-store": "^1.0.1", "@types/throttle-debounce": "^1.0.0", "@types/zxcvbn": "^4.4.0", - "@vitejs/plugin-react-swc": "^3.7.2", - "@vitest/coverage-v8": "^3.1.2", + "@vitejs/plugin-react-swc": "^4.0.1", + "@vitest/coverage-v8": "^3.2.4", "@vueless/storybook-dark-mode": "^9.0.5", "axe-core": "^4.10.2", "chai-string": "^1.5.0", @@ -168,7 +168,7 @@ "cypress-multi-reporters": "^2.0.5", "cypress-on-fix": "^1.1.0", "cypress-real-events": "^1.14.0", - "cypress-vite": "^1.7.0", + "cypress-vite": "^1.8.0", "dotenv": "^16.0.3", "factory.ts": "^0.5.1", "glob": "^10.3.1", @@ -180,8 +180,8 @@ "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", "storybook": "^9.0.12", - "vite": "^6.3.6", - "vite-plugin-svgr": "^3.2.0" + "vite": "^7.1.11", + "vite-plugin-svgr": "^4.5.0" }, "browserslist": [ ">1%", diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index 7b31c96ccf1..da562e52401 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -13,7 +13,11 @@ export default defineConfig({ outDir: 'build', }, envPrefix: 'REACT_APP_', - plugins: [react(), svgr({ exportAsDefault: true }), urlCanParsePolyfill()], + plugins: [ + react(), + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + urlCanParsePolyfill(), + ], resolve: { alias: { src: `${DIRNAME}/src`, diff --git a/packages/shared/package.json b/packages/shared/package.json index f586871fe92..8055af74a58 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -47,6 +47,6 @@ "@testing-library/user-event": "^14.5.2", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", - "vite-plugin-svgr": "^3.2.0" + "vite-plugin-svgr": "^4.5.0" } } diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index ba5b20959d2..243b2d4dd38 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -2,7 +2,9 @@ import svgr from 'vite-plugin-svgr'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [svgr({ exportAsDefault: true })], + plugins: [ + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + ], test: { environment: 'jsdom', diff --git a/packages/ui/package.json b/packages/ui/package.json index 261a6775836..6ee7aab5aea 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -54,6 +54,6 @@ "@types/luxon": "3.4.2", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", - "vite-plugin-svgr": "^3.2.0" + "vite-plugin-svgr": "^4.5.0" } } diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 5c794ec63e3..a50c6e09086 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -2,7 +2,9 @@ import svgr from 'vite-plugin-svgr'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [svgr({ exportAsDefault: true })], + plugins: [ + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + ], test: { environment: 'jsdom', setupFiles: './testSetup.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa732be0033..4b9a3e2becd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: ^8.38.0 version: 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) '@vitest/ui': - specifier: ^3.1.2 - version: 3.1.2(vitest@3.1.2) + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) concurrently: specifier: 9.1.0 version: 9.1.0 @@ -85,8 +85,8 @@ importers: specifier: ^8.29.0 version: 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) vitest: - specifier: ^3.1.2 - version: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) packages/api-v4: dependencies: @@ -114,7 +114,7 @@ importers: version: 9.1.0 tsup: specifier: ^8.4.0 - version: 8.4.0(@swc/core@1.10.11)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) packages/manager: dependencies: @@ -358,7 +358,7 @@ importers: version: 9.0.12(@types/react@19.1.6)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@swc/core': specifier: ^1.10.9 version: 1.10.11 @@ -441,11 +441,11 @@ importers: specifier: ^4.4.0 version: 4.4.5 '@vitejs/plugin-react-swc': - specifier: ^3.7.2 - version: 3.7.2(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^4.0.1 + version: 4.0.1(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': - specifier: ^3.1.2 - version: 3.1.2(vitest@3.1.2) + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) '@vueless/storybook-dark-mode': specifier: ^9.0.5 version: 9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -483,8 +483,8 @@ importers: specifier: ^1.14.0 version: 1.14.0(cypress@15.4.0) cypress-vite: - specifier: ^1.7.0 - version: 1.8.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^1.8.0 + version: 1.8.0(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) dotenv: specifier: ^16.0.3 version: 16.4.5 @@ -519,11 +519,11 @@ importers: specifier: ^9.0.12 version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite: - specifier: ^6.3.6 - version: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + specifier: ^7.1.11 + version: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-plugin-svgr: - specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^4.5.0 + version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -575,7 +575,7 @@ importers: version: 4.2.0 vite: specifier: '*' - version: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) devDependencies: '@linode/tsconfig': specifier: workspace:* @@ -607,7 +607,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -630,8 +630,8 @@ importers: specifier: ^9.0.12 version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: - specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^4.5.0 + version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -676,7 +676,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -702,8 +702,8 @@ importers: specifier: ^9.0.12 version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: - specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^4.5.0 + version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -765,7 +765,7 @@ importers: version: 9.1.0 tsup: specifier: ^8.4.0 - version: 8.4.0(@swc/core@1.10.11)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) scripts: devDependencies: @@ -1921,6 +1921,9 @@ packages: react: ^16.8.0 || 17.x react-dom: ^16.8.0 || 17.x + '@rolldown/pluginutils@1.0.0-beta.32': + resolution: {integrity: sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==} + '@rollup/pluginutils@5.1.3': resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} @@ -1930,6 +1933,15 @@ packages: rollup: optional: true + '@rollup/pluginutils@5.2.0': + resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.40.1': resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==} cpu: [arm] @@ -2326,60 +2338,120 @@ packages: cpu: [arm64] os: [darwin] + '@swc/core-darwin-arm64@1.13.5': + resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + '@swc/core-darwin-x64@1.10.11': resolution: {integrity: sha512-szObinnq2o7spXMDU5pdunmUeLrfV67Q77rV+DyojAiGJI1RSbEQotLOk+ONOLpoapwGUxOijFG4IuX1xiwQ2g==} engines: {node: '>=10'} cpu: [x64] os: [darwin] + '@swc/core-darwin-x64@1.13.5': + resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + '@swc/core-linux-arm-gnueabihf@1.10.11': resolution: {integrity: sha512-tVE8aXQwd8JUB9fOGLawFJa76nrpvp3dvErjozMmWSKWqtoeO7HV83aOrVtc8G66cj4Vq7FjTE9pOJeV1FbKRw==} engines: {node: '>=10'} cpu: [arm] os: [linux] + '@swc/core-linux-arm-gnueabihf@1.13.5': + resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + '@swc/core-linux-arm64-gnu@1.10.11': resolution: {integrity: sha512-geFkENU5GMEKO7FqHOaw9HVlpQEW10nICoM6ubFc0hXBv8dwRXU4vQbh9s/isLSFRftw1m4jEEWixAnXSw8bxQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + '@swc/core-linux-arm64-gnu@1.13.5': + resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + '@swc/core-linux-arm64-musl@1.10.11': resolution: {integrity: sha512-2mMscXe/ivq8c4tO3eQSbQDFBvagMJGlalXCspn0DgDImLYTEnt/8KHMUMGVfh0gMJTZ9q4FlGLo7mlnbx99MQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + '@swc/core-linux-arm64-musl@1.13.5': + resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + '@swc/core-linux-x64-gnu@1.10.11': resolution: {integrity: sha512-eu2apgDbC4xwsigpl6LS+iyw6a3mL6kB4I+6PZMbFF2nIb1Dh7RGnu70Ai6mMn1o80fTmRSKsCT3CKMfVdeNFg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + '@swc/core-linux-x64-gnu@1.13.5': + resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + '@swc/core-linux-x64-musl@1.10.11': resolution: {integrity: sha512-0n+wPWpDigwqRay4IL2JIvAqSKCXv6nKxPig9M7+epAlEQlqX+8Oq/Ap3yHtuhjNPb7HmnqNJLCXT1Wx+BZo0w==} engines: {node: '>=10'} cpu: [x64] os: [linux] + '@swc/core-linux-x64-musl@1.13.5': + resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + '@swc/core-win32-arm64-msvc@1.10.11': resolution: {integrity: sha512-7+bMSIoqcbXKosIVd314YjckDRPneA4OpG1cb3/GrkQTEDXmWT3pFBBlJf82hzJfw7b6lfv6rDVEFBX7/PJoLA==} engines: {node: '>=10'} cpu: [arm64] os: [win32] + '@swc/core-win32-arm64-msvc@1.13.5': + resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + '@swc/core-win32-ia32-msvc@1.10.11': resolution: {integrity: sha512-6hkLl4+3KjP/OFTryWxpW7YFN+w4R689TSPwiII4fFgsFNupyEmLWWakKfkGgV2JVA59L4Oi02elHy/O1sbgtw==} engines: {node: '>=10'} cpu: [ia32] os: [win32] + '@swc/core-win32-ia32-msvc@1.13.5': + resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + '@swc/core-win32-x64-msvc@1.10.11': resolution: {integrity: sha512-kKNE2BGu/La2k2WFHovenqZvGQAHRIU+rd2/6a7D6EiQ6EyimtbhUqjCCZ+N1f5fIAnvM+sMdLiQJq4jdd/oOQ==} engines: {node: '>=10'} cpu: [x64] os: [win32] + '@swc/core-win32-x64-msvc@1.13.5': + resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + '@swc/core@1.10.11': resolution: {integrity: sha512-3zGU5y3S20cAwot9ZcsxVFNsSVaptG+dKdmAxORSE3EX7ixe1Xn5kUwLlgIsM4qrwTUWCJDLNhRS+2HLFivcDg==} engines: {node: '>=10'} @@ -2389,12 +2461,24 @@ packages: '@swc/helpers': optional: true + '@swc/core@1.13.5': + resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} '@swc/types@0.1.17': resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==} + '@swc/types@0.1.24': + resolution: {integrity: sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==} + '@tanstack/history@1.99.13': resolution: {integrity: sha512-JMd7USmnp8zV8BRGIjALqzPxazvKtQ7PGXQC7n39HpbqdsmfV2ePCzieO84IvN+mwsTrXErpbjI4BfKCa+ZNCg==} engines: {node: '>=12'} @@ -2499,6 +2583,9 @@ packages: '@types/chai@5.0.1': resolution: {integrity: sha512-5T8ajsg3M/FOncpLYW7sdOcD6yf4+722sze/tc4KQV0P8Z2rAr3SAuHCIkYmYpt8VbcQlnz8SxlOlPQYefe4cA==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chart.js@2.9.41': resolution: {integrity: sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==} @@ -2798,16 +2885,17 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-react-swc@3.7.2': - resolution: {integrity: sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==} + '@vitejs/plugin-react-swc@4.0.1': + resolution: {integrity: sha512-NQhPjysi5duItyrMd5JWZFf2vNOuSMyw+EoZyTBDzk+DkfYD8WNrsUs09sELV2cr1P15nufsN25hsUBt4CKF9Q==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4 || ^5 || ^6 + vite: ^4 || ^5 || ^6 || ^7 - '@vitest/coverage-v8@3.1.2': - resolution: {integrity: sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: - '@vitest/browser': 3.1.2 - vitest: 3.1.2 + '@vitest/browser': 3.2.4 + vitest: 3.2.4 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2815,14 +2903,14 @@ packages: '@vitest/expect@3.0.9': resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} - '@vitest/expect@3.1.2': - resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@3.1.2': - resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true @@ -2832,31 +2920,31 @@ packages: '@vitest/pretty-format@3.0.9': resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} - '@vitest/pretty-format@3.1.2': - resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/runner@3.1.2': - resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/snapshot@3.1.2': - resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} '@vitest/spy@3.0.9': resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} - '@vitest/spy@3.1.2': - resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/ui@3.1.2': - resolution: {integrity: sha512-+YPgKiLpFEyBVJNHDkRcSDcLrrnr20lyU4HQoI9Jtq1MdvoX8usql9h38mQw82MBU1Zo5BPC6sw+sXZ6NS18CQ==} + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} peerDependencies: - vitest: 3.1.2 + vitest: 3.2.4 '@vitest/utils@3.0.9': resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} - '@vitest/utils@3.1.2': - resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@vueless/storybook-dark-mode@9.0.5': resolution: {integrity: sha512-JU0bQe+KHvmg04k2yprzVkM0d8xdKwqFaFuQmO7afIUm//ttroDpfHfPzwLZuTDW9coB5bt2+qMSHZOBbt0w4g==} @@ -3003,6 +3091,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.4: + resolution: {integrity: sha512-cxrAnZNLBnQwBPByK4CeDaw5sWZtMilJE/Q3iDA0aamgaIVNDF9T6K2/8DfYDZEejZ2jNnDrG9m8MY72HFd0KA==} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -3687,8 +3778,8 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -4553,6 +4644,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -4764,6 +4858,9 @@ packages: loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -5810,6 +5907,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -5904,12 +6004,16 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@2.0.0: @@ -5920,6 +6024,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.61: resolution: {integrity: sha512-In7VffkDWUPgwa+c9picLUxvb0RltVwTkSgMNFgvlGSWveCzGBemBqTsgJCL4EDFWZ6WH0fKTsot6yNhzy3ZzQ==} @@ -6192,29 +6300,29 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - vite-node@3.1.2: - resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-plugin-svgr@3.3.0: - resolution: {integrity: sha512-vWZMCcGNdPqgziYFKQ3Y95XP0d0YGp28+MM3Dp9cTa/px5CKcHHrIoPl2Jw81rgVm6/ZUNONzjXbZQZ7Kw66og==} + vite-plugin-svgr@4.5.0: + resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} peerDependencies: - vite: ^2.6.0 || 3 || 4 + vite: '>=2.6.0' - vite@6.3.6: - resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vite@7.1.11: + resolution: {integrity: sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 jiti: '>=1.21.0' - less: '*' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.3.0 @@ -6242,16 +6350,56 @@ packages: yaml: optional: true - vitest@3.1.2: - resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==} + vite@7.1.3: + resolution: {integrity: sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.3.0 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.1.2 - '@vitest/ui': 3.1.2 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -6537,8 +6685,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@babel/code-frame@7.26.2': dependencies: @@ -6561,7 +6709,7 @@ snapshots: '@babel/traverse': 7.25.9 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 7.6.3 @@ -6572,8 +6720,8 @@ snapshots: dependencies: '@babel/parser': 7.27.0 '@babel/types': 7.27.0 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.25.9': @@ -6632,7 +6780,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -7202,12 +7350,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 10.4.5 magic-string: 0.30.17 react-docgen-typescript: 2.2.2(typescript@5.7.3) - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) optionalDependencies: typescript: 5.7.3 @@ -7215,7 +7363,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - optional: true '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -7235,8 +7382,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.25': dependencies: @@ -7247,11 +7393,10 @@ snapshots: dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - optional: true '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7517,6 +7662,8 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@rolldown/pluginutils@1.0.0-beta.32': {} + '@rollup/pluginutils@5.1.3(rollup@4.50.1)': dependencies: '@types/estree': 1.0.7 @@ -7525,6 +7672,14 @@ snapshots: optionalDependencies: rollup: 4.50.1 + '@rollup/pluginutils@5.2.0(rollup@4.50.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.50.1 + '@rollup/rollup-android-arm-eabi@4.40.1': optional: true @@ -7735,12 +7890,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@storybook/csf-plugin': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) ts-dedent: 2.2.0 - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@storybook/csf-plugin@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': dependencies: @@ -7765,11 +7920,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) - '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@rollup/pluginutils': 5.1.3(rollup@4.50.1) - '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/react': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3) find-up: 7.0.0 magic-string: 0.30.17 @@ -7779,7 +7934,7 @@ snapshots: resolve: 1.22.8 storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) tsconfig-paths: 4.2.0 - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color @@ -7868,33 +8023,63 @@ snapshots: '@swc/core-darwin-arm64@1.10.11': optional: true + '@swc/core-darwin-arm64@1.13.5': + optional: true + '@swc/core-darwin-x64@1.10.11': optional: true + '@swc/core-darwin-x64@1.13.5': + optional: true + '@swc/core-linux-arm-gnueabihf@1.10.11': optional: true + '@swc/core-linux-arm-gnueabihf@1.13.5': + optional: true + '@swc/core-linux-arm64-gnu@1.10.11': optional: true + '@swc/core-linux-arm64-gnu@1.13.5': + optional: true + '@swc/core-linux-arm64-musl@1.10.11': optional: true + '@swc/core-linux-arm64-musl@1.13.5': + optional: true + '@swc/core-linux-x64-gnu@1.10.11': optional: true + '@swc/core-linux-x64-gnu@1.13.5': + optional: true + '@swc/core-linux-x64-musl@1.10.11': optional: true + '@swc/core-linux-x64-musl@1.13.5': + optional: true + '@swc/core-win32-arm64-msvc@1.10.11': optional: true + '@swc/core-win32-arm64-msvc@1.13.5': + optional: true + '@swc/core-win32-ia32-msvc@1.10.11': optional: true + '@swc/core-win32-ia32-msvc@1.13.5': + optional: true + '@swc/core-win32-x64-msvc@1.10.11': optional: true + '@swc/core-win32-x64-msvc@1.13.5': + optional: true + '@swc/core@1.10.11': dependencies: '@swc/counter': 0.1.3 @@ -7911,12 +8096,32 @@ snapshots: '@swc/core-win32-ia32-msvc': 1.10.11 '@swc/core-win32-x64-msvc': 1.10.11 + '@swc/core@1.13.5': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.24 + optionalDependencies: + '@swc/core-darwin-arm64': 1.13.5 + '@swc/core-darwin-x64': 1.13.5 + '@swc/core-linux-arm-gnueabihf': 1.13.5 + '@swc/core-linux-arm64-gnu': 1.13.5 + '@swc/core-linux-arm64-musl': 1.13.5 + '@swc/core-linux-x64-gnu': 1.13.5 + '@swc/core-linux-x64-musl': 1.13.5 + '@swc/core-win32-arm64-msvc': 1.13.5 + '@swc/core-win32-ia32-msvc': 1.13.5 + '@swc/core-win32-x64-msvc': 1.13.5 + '@swc/counter@0.1.3': {} '@swc/types@0.1.17': dependencies: '@swc/counter': 0.1.3 + '@swc/types@0.1.24': + dependencies: + '@swc/counter': 0.1.3 + '@tanstack/history@1.99.13': {} '@tanstack/query-core@5.51.24': {} @@ -8047,6 +8252,10 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/chart.js@2.9.41': dependencies: moment: 2.30.1 @@ -8283,7 +8492,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.7.3) '@typescript-eslint/types': 8.38.0 - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -8306,7 +8515,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.7.3) '@typescript-eslint/utils': 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) eslint: 9.31.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 @@ -8333,7 +8542,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8393,18 +8602,20 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@3.7.2(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@4.0.1(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@swc/core': 1.10.11 - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + '@rolldown/pluginutils': 1.0.0-beta.32 + '@swc/core': 1.13.5 + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.1.2(vitest@3.1.2)': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.0 + ast-v8-to-istanbul: 0.3.4 + debug: 4.4.1(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -8414,7 +8625,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -8425,38 +8636,40 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/expect@3.1.2': + '@vitest/expect@3.2.4': dependencies: - '@vitest/spy': 3.1.2 - '@vitest/utils': 3.1.2 + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/mocker@3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@vitest/spy': 3.1.2 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/pretty-format@3.0.9': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@3.1.2': + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.1.2': + '@vitest/runner@3.2.4': dependencies: - '@vitest/utils': 3.1.2 + '@vitest/utils': 3.2.4 pathe: 2.0.3 + strip-literal: 3.0.0 - '@vitest/snapshot@3.1.2': + '@vitest/snapshot@3.2.4': dependencies: - '@vitest/pretty-format': 3.1.2 + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 pathe: 2.0.3 @@ -8464,20 +8677,20 @@ snapshots: dependencies: tinyspy: 3.0.2 - '@vitest/spy@3.1.2': + '@vitest/spy@3.2.4': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.3 - '@vitest/ui@3.1.2(vitest@3.1.2)': + '@vitest/ui@3.2.4(vitest@3.2.4)': dependencies: - '@vitest/utils': 3.1.2 + '@vitest/utils': 3.2.4 fflate: 0.8.2 flatted: 3.3.3 pathe: 2.0.3 sirv: 3.0.1 - tinyglobby: 0.2.13 + tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/utils@3.0.9': dependencies: @@ -8485,10 +8698,10 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 - '@vitest/utils@3.1.2': + '@vitest/utils@3.2.4': dependencies: - '@vitest/pretty-format': 3.1.2 - loupe: 3.1.3 + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 tinyrainbow: 2.0.0 '@vueless/storybook-dark-mode@9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -8513,7 +8726,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8670,6 +8883,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + astral-regex@2.0.0: {} asynckit@0.4.0: {} @@ -8842,7 +9061,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.3 + loupe: 3.2.1 pathval: 2.0.0 chalk@3.0.0: @@ -9107,11 +9326,11 @@ snapshots: dependencies: cypress: 15.4.0 - cypress-vite@1.8.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + cypress-vite@1.8.0(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: chokidar: 3.6.0 debug: 4.4.1(supports-color@8.1.1) - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -9436,7 +9655,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: @@ -9807,6 +10026,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.4(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -10376,8 +10599,8 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.0 + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.1(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -10409,6 +10632,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -10667,6 +10892,8 @@ snapshots: loupe@3.1.3: {} + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -11859,6 +12086,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + stylis@4.2.0: {} sucrase@3.35.0: @@ -11949,17 +12180,24 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.4(picomatch@4.0.3) + picomatch: 4.0.3 + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.0.2: {} + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} tinyspy@3.0.2: {} + tinyspy@4.0.3: {} + tldts-core@6.1.61: {} tldts@6.1.61: @@ -12029,7 +12267,7 @@ snapshots: optionalDependencies: '@mui/material': 7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - tsup@8.4.0(@swc/core@1.10.11)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1): + tsup@8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.3) cac: 6.7.14 @@ -12048,7 +12286,7 @@ snapshots: tinyglobby: 0.2.13 tree-kill: 1.2.2 optionalDependencies: - '@swc/core': 1.10.11 + '@swc/core': 1.13.5 postcss: 8.5.6 typescript: 5.7.3 transitivePeerDependencies: @@ -12251,13 +12489,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.1.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite-node@3.2.4(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@8.1.1) - es-module-lexer: 1.6.0 + es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@types/node' - jiti @@ -12272,18 +12510,34 @@ snapshots: - tsx - yaml - vite-plugin-svgr@3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.50.1) + '@rollup/pluginutils': 5.2.0(rollup@4.50.1) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + dependencies: + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.18.1 + fsevents: 2.3.3 + jiti: 2.4.2 + terser: 5.36.0 + tsx: 4.19.3 + yaml: 2.6.1 + + vite@7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -12299,33 +12553,35 @@ snapshots: tsx: 4.19.3 yaml: 2.6.1 - vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: - '@vitest/expect': 3.1.2 - '@vitest/mocker': 3.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@vitest/pretty-format': 3.1.2 - '@vitest/runner': 3.1.2 - '@vitest/snapshot': 3.1.2 - '@vitest/spy': 3.1.2 - '@vitest/utils': 3.1.2 + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 - debug: 4.4.0 + debug: 4.4.1(supports-color@8.1.1) expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 + picomatch: 4.0.3 std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.13 - tinypool: 1.0.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - vite-node: 3.1.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite-node: 3.2.4(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.18.1 - '@vitest/ui': 3.1.2(vitest@3.1.2) + '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 24.1.3 transitivePeerDependencies: - jiti From 38e5b76c36e6719ef8404b3b087a8691f7ae9413 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Wed, 22 Oct 2025 07:48:44 +0200 Subject: [PATCH 09/39] test: [DPS-34824] - Add cypress tests for logs destinations (#12936) * test: [DPS-34824] - Add cypress tests for logs destinations --- .../pr-12936-tests-1759312166894.md | 5 + .../core/delivery/create-destination.spec.ts | 99 +++++++++++ .../destinations-empty-landing-page.spec.ts | 37 +++++ ...estinations-non-empty-landing-page.spec.ts | 154 ++++++++++++++++++ .../core/delivery/edit-destination.spec.ts | 110 +++++++++++++ .../manager/cypress/support/api/delivery.ts | 45 +++++ .../cypress/support/constants/delivery.ts | 32 ++++ .../cypress/support/intercepts/delivery.ts | 136 ++++++++++++++++ .../support/ui/pages/logs-destination-form.ts | 98 +++++++++++ .../manager/cypress/support/util/cleanup.ts | 3 + packages/manager/src/factories/index.ts | 1 + .../DestinationActionMenu.test.tsx | 2 +- .../DestinationForm/DestinationEdit.test.tsx | 2 +- .../Destinations/DestinationsLanding.test.tsx | 2 +- .../Streams/StreamActionMenu.test.tsx | 2 +- .../Delivery/StreamFormDelivery.test.tsx | 2 +- .../Streams/StreamForm/StreamCreate.test.tsx | 2 +- .../Streams/StreamForm/StreamEdit.test.tsx | 2 +- .../Delivery/Streams/StreamTableRow.test.tsx | 2 +- .../Delivery/Streams/StreamsLanding.test.tsx | 2 +- .../mocks/presets/crud/handlers/delivery.ts | 2 +- 21 files changed, 730 insertions(+), 10 deletions(-) create mode 100644 packages/manager/.changeset/pr-12936-tests-1759312166894.md create mode 100644 packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts create mode 100644 packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts create mode 100644 packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts create mode 100644 packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts create mode 100644 packages/manager/cypress/support/api/delivery.ts create mode 100644 packages/manager/cypress/support/constants/delivery.ts create mode 100644 packages/manager/cypress/support/intercepts/delivery.ts create mode 100644 packages/manager/cypress/support/ui/pages/logs-destination-form.ts diff --git a/packages/manager/.changeset/pr-12936-tests-1759312166894.md b/packages/manager/.changeset/pr-12936-tests-1759312166894.md new file mode 100644 index 00000000000..f49e7789f8a --- /dev/null +++ b/packages/manager/.changeset/pr-12936-tests-1759312166894.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Logs Destination Landing, Create and Edit e2e tests ([#12936](https://github.com/linode/manager/pull/12936)) diff --git a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts new file mode 100644 index 00000000000..31c74fff140 --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts @@ -0,0 +1,99 @@ +import { + mockDestination, + mockDestinationPayload, +} from 'support/constants/delivery'; +import { + mockCreateDestination, + mockGetDestinations, + mockTestConnection, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; +import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; + +import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; + +describe('Create Destination', () => { + before(() => { + mockAppendFeatureFlags({ + aclpLogs: { enabled: true, beta: true }, + }); + }); + + it('create destination with form', () => { + cy.visitWithLogin('/logs/delivery/destinations/create'); + + // Give Destination a label + logsDestinationForm.setLabel(mockDestinationPayload.label); + + logsDestinationForm.fillDestinationDetailsForm( + mockDestinationPayload.details as AkamaiObjectStorageDetails + ); + + // Create Destination should be disabled before test connection + cy.findByRole('button', { name: 'Create Destination' }).should( + 'be.disabled' + ); + + // Test connection of the destination form - failure + mockTestConnection(400); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + 'Delivery connection test failed. Verify your delivery settings and try again.' + ); + + // Create Destination should be disabled after test connection failed + cy.findByRole('button', { name: 'Create Destination' }).should( + 'be.disabled' + ); + + // Test connection of the destination form - success + mockTestConnection(200); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Delivery connection test completed successfully. Data can now be sent using this configuration.` + ); + + // Submit the destination create form - failure + mockCreateDestination({}, 400); + cy.findByRole('button', { name: 'Create Destination' }) + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage(`There was an issue creating your destination`); + + // Submit the destination create form - success + mockCreateDestination(mockDestination); + mockGetDestinations([mockDestination]); + cy.findByRole('button', { name: 'Create Destination' }) + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Destination ${mockDestination.label} created successfully` + ); + + // Verify we redirect to the destinations landing page upon successful creation + cy.url().should('endWith', 'destinations'); + + // Verify the newly created destination shows on the Destinations landing page + cy.findByText(mockDestination.label) + .closest('tr') + .within(() => { + // Verify Destination label shows + cy.findByText(mockDestination.label).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts new file mode 100644 index 00000000000..ff8de40f562 --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts @@ -0,0 +1,37 @@ +import { mockGetDestinations } from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +describe('Destinations empty landing page', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { + enabled: true, + beta: true, + }, + }); + }); + + /** + * - Confirms Destinations landing page empty state is shown when no Destinations are present: + * - Confirms that clicking "Create Destination" navigates user to create destination page. + */ + it('shows the empty state when there are no destinations', () => { + mockGetDestinations([]).as('getDestinations'); + + cy.visitWithLogin('/logs/delivery/destinations'); + cy.wait(['@getDestinations']); + + // Confirm empty Destinations Landing Text + cy.findByText('Create a destination for cloud logs').should('be.visible'); + + // confirms clicking on 'Create Domain' button + ui.button + .findByTitle('Create Destination') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/logs/delivery/destinations/create'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts new file mode 100644 index 00000000000..ea06005cebf --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts @@ -0,0 +1,154 @@ +import { + interceptDeleteDestination, + mockDeleteDestination, + mockGetDestination, + mockGetDestinations, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +import { destinationFactory } from 'src/factories'; + +import type { Destination } from '@linode/api-v4'; + +function checkActionMenu(tableAlias: string, mockDestinations: Destination[]) { + mockDestinations.forEach((destination) => { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', destination.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Destination ${destination.label}`) + .should('be.visible') + .click(); + + // Check that all items are enabled + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled'); + + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled'); + }); + + // Close the action menu by clicking on Delivery Title of the screen + cy.get('body').click(0, 0); + }); +} + +function deleteItem(tableAlias: string, destination: Destination) { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', destination.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Destination ${destination.label}`) + .should('be.visible') + .click(); + + mockDeleteDestination(404); // @TODO remove after API release on prod + interceptDeleteDestination().as('deleteDestination'); + + // Delete destination + ui.actionMenuItem.findByTitle('Delete').click(); + + // Find confirmation modal + cy.findByText( + `Are you sure you want to delete "${destination.label}" destination?` + ); + ui.button.findByTitle('Delete').click(); + + cy.wait('@deleteDestination'); + + // Close confirmation modal after failure + ui.button.findByTitle('Cancel').click(); + }); +} + +function editItemViaActionMenu(tableAlias: string, destination: Destination) { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', destination.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Destination ${destination.label}`) + .should('be.visible') + .click(); + + mockGetDestination(destination); + // Edit destination redirect + ui.actionMenuItem.findByTitle('Edit').click(); + cy.url().should('endWith', `/destinations/${destination.id}/edit`); + }); +} + +const mockDestinations: Destination[] = new Array(3) + .fill(null) + .map((_item: null, index: number): Destination => { + return destinationFactory.build({ + label: `Destination ${index}`, + }); + }); + +describe('destinations landing checks for non-empty state', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { enabled: true, beta: true }, + }); + + // Mock setup to display the Destinations landing page in a non-empty state + mockGetDestinations(mockDestinations).as('getDestinations'); + + // Alias the mockDestinations array + cy.wrap(mockDestinations).as('mockDestinations'); + }); + + it('checks create destination button is enabled and user can see existing destinations', () => { + // Login and wait for application to load + cy.visitWithLogin('/logs/delivery/destinations'); + cy.wait('@getDestinations'); + cy.url().should('endWith', '/destinations'); + + cy.get('table').should('exist').as('destinationsTable'); + + // Assert that Create Destination button is visible and enabled + ui.button + .findByTitle('Create Destination') + .should('be.visible') + .and('be.enabled'); + + // Assert that the correct number of Destinations entries are present in the DestinationsTable + cy.get('@destinationsTable') + .find('tbody tr') + .should('have.length', mockDestinations.length); + + checkActionMenu('@destinationsTable', mockDestinations); // For the recovery destination table + }); + + it('checks actions from destination menu actions', () => { + cy.visitWithLogin('/logs/delivery/destinations'); + cy.wait('@getDestinations'); + cy.get('table').should('exist').as('destinationsTable'); + + const exampleDestination = mockDestinations[0]; + deleteItem('@destinationsTable', exampleDestination); + + mockGetDestination(exampleDestination).as('getDestination'); + + // Redirect to destination edit page via name + cy.findByText(exampleDestination.label).click(); + cy.url().should('endWith', `/destinations/${exampleDestination.id}/edit`); + cy.wait('@getDestination'); + + cy.visit('/logs/delivery/destinations'); + cy.get('table').should('exist').as('destinationsTable'); + cy.wait('@getDestinations'); + editItemViaActionMenu('@destinationsTable', exampleDestination); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts new file mode 100644 index 00000000000..2f458580034 --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts @@ -0,0 +1,110 @@ +import { + mockDestination, + mockDestinationPayload, +} from 'support/constants/delivery'; +import { + mockGetDestination, + mockGetDestinations, + mockTestConnection, + mockUpdateDestination, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; +import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; +import { randomLabel } from 'support/util/random'; + +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; + +import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; + +describe('Edit Destination', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { enabled: true, beta: true }, + }); + cy.visitWithLogin(`/logs/delivery/destinations/${mockDestination.id}/edit`); + mockGetDestination(mockDestination); + }); + + it('destination type edit should be disabled', () => { + cy.findByLabelText('Destination Type') + .should('be.visible') + .should('be.disabled') + .should( + 'have.attr', + 'value', + getDestinationTypeOption(mockDestination.type)?.label + ); + }); + + it('edit destination with incorrect data', () => { + logsDestinationForm.fillDestinationDetailsForm( + mockDestinationPayload.details as AkamaiObjectStorageDetails + ); + + // Create Destination should be disabled before test connection + cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Test connection of the destination form + mockTestConnection(400); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + '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'); + }); + + it('edit destination with correct data', () => { + const newLabel = randomLabel(); + // Give Destination a new label + logsDestinationForm.setLabel(newLabel); + + logsDestinationForm.fillDestinationDetailsForm( + mockDestinationPayload.details as AkamaiObjectStorageDetails + ); + + // Create Destination should be disabled before test connection + cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Test connection of the destination form + mockTestConnection(); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Delivery connection test completed successfully. Data can now be sent using this configuration.` + ); + + const updatedDestination = { ...mockDestination, label: newLabel }; + mockUpdateDestination(mockDestination, updatedDestination); + mockGetDestinations([updatedDestination]); + // Submit the destination edit form + cy.findByRole('button', { name: 'Edit Destination' }) + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Destination ${updatedDestination.label} edited successfully` + ); + + // Verify we redirect to the destinations landing page upon successful edit + cy.url().should('endWith', 'destinations'); + + // Verify the edited destination shows on the Destinations landing page + cy.findByText(newLabel) + .closest('tr') + .within(() => { + // Verify Destination label shows + cy.findByText(newLabel).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/support/api/delivery.ts b/packages/manager/cypress/support/api/delivery.ts new file mode 100644 index 00000000000..d251e9f1cb8 --- /dev/null +++ b/packages/manager/cypress/support/api/delivery.ts @@ -0,0 +1,45 @@ +import { + deleteDestination, + deleteStream, + getDestinations, + getStreams, +} from '@linode/api-v4'; +import { isTestLabel } from 'support/api/common'; +import { pageSize } from 'support/constants/api'; +import { depaginate } from 'support/util/paginate'; + +import type { Destination, Stream } from '@linode/api-v4'; + +/** + * Deletes all destinations which are prefixed with the test entity prefix. + * + * @returns Promise that resolves when destinations have been deleted. + */ +export const deleteAllTestDestinations = async (): Promise => { + const destinations = await depaginate((page: number) => + getDestinations({ page, page_size: pageSize }) + ); + + const deletionPromises = destinations + .filter((destination: Destination) => isTestLabel(destination.label)) + .map((destination: Destination) => deleteDestination(destination.id)); + + await Promise.all(deletionPromises); +}; + +/** + * Deletes all streams which are prefixed with the test entity prefix. + * + * @returns Promise that resolves when streams have been deleted. + */ +export const deleteAllTestStreams = async (): Promise => { + const streams = await depaginate((page: number) => + getStreams({ page, page_size: pageSize }) + ); + + const deletionPromises = streams + .filter((destination: Stream) => isTestLabel(destination.label)) + .map((destination: Stream) => deleteStream(destination.id)); + + await Promise.all(deletionPromises); +}; diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts new file mode 100644 index 00000000000..e2be89d8c08 --- /dev/null +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -0,0 +1,32 @@ +import { destinationType } from '@linode/api-v4'; +import { randomLabel, randomString } from 'support/util/random'; + +import { destinationFactory } from 'src/factories'; + +import type { Destination } from '@linode/api-v4'; + +export const regions = [ + { + id: 'pl-labkrk-2', + label: 'PL, Krakow (pl-labkrk-2)', + }, +]; + +export const mockDestinationPayload = { + label: randomLabel(), + type: destinationType.AkamaiObjectStorage, + details: { + host: randomString(), + bucket_name: randomString(), + region: 'pl-labkrk-2', + access_key_id: randomString(), + access_key_secret: randomString(), + path: '/', + }, +}; + +export const mockDestination: Destination = destinationFactory.build({ + id: 1290, + ...mockDestinationPayload, + version: '1.0', +}); diff --git a/packages/manager/cypress/support/intercepts/delivery.ts b/packages/manager/cypress/support/intercepts/delivery.ts new file mode 100644 index 00000000000..03dff6f8db8 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/delivery.ts @@ -0,0 +1,136 @@ +/** + * @file Cypress intercepts and mocks for Logs Delivery API requests. + */ + +import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; + +import type { + Destination, + UpdateDestinationPayloadWithId, +} from '@linode/api-v4'; + +/** + * Intercepts GET request to fetch destination instance and mocks response. + * + * @param destination - Response destinations. + * + * @returns Cypress chainable. + */ +export const mockGetDestination = ( + destination: Destination +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`monitor/streams/destinations/${destination.id}`), + makeResponse(destination) + ); +}; + +/** + * Intercepts GET request to mock destination data. + * + * @param destinations - an array of mock destination objects. + * + * @returns Cypress chainable. + */ +export const mockGetDestinations = ( + destinations: Destination[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('monitor/streams/destinations*'), + paginateResponse(destinations) + ); +}; + +/** + * Intercepts POST request to create a Destination record. + * + * @returns Cypress chainable. + */ +export const interceptCreateDestination = (): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher('monitor/streams/destinations*')); +}; + +/** + * Intercepts DELETE request to delete Destination record. + * + * @returns Cypress chainable. + */ +export const interceptDeleteDestination = (): Cypress.Chainable => { + return cy.intercept('DELETE', apiMatcher(`monitor/streams/destinations/*`)); +}; + +/** + * Intercepts PUT request to update a destination and mocks response. + * + * @param destination - Destination data to update. + * @param responseBody - Full updated destination object. + * + * @returns Cypress chainable. + */ +export const mockUpdateDestination = ( + destination: UpdateDestinationPayloadWithId, + responseBody: Destination +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`monitor/streams/destinations/${destination.id}`), + makeResponse(responseBody) + ); +}; + +/** + * Intercepts POST request to create a destination and mocks response. + * + * @param responseCode + * @param responseBody - Full destination object returned when created. + * + * @returns Cypress chainable. + */ +export const mockCreateDestination = ( + responseBody = {}, + responseCode = 200 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`monitor/streams/destinations`), + makeResponse(responseBody ?? {}, responseCode) + ); +}; + +/** + * Intercepts POST request to verify destination connection. + * + * @param responseCode - status code of the response. + * @param responseBody - response body content. + * + * @returns Cypress chainable. + */ +export const mockTestConnection = ( + responseCode = 200, + responseBody = {} +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`monitor/streams/destinations/verify`), + makeResponse(responseBody, responseCode) + ); +}; + +/** + * Intercept DELETE mock request to delete a Destination record. + * + * @returns Cypress chainable. + */ +export const mockDeleteDestination = ( + responseCode = 200 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`monitor/streams/destinations/*`), + makeResponse({}, responseCode) + ); +}; diff --git a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts new file mode 100644 index 00000000000..8c1f4dc7c8b --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts @@ -0,0 +1,98 @@ +/** + * @file Page utilities for Logs Delivery Destination Form. + * Create/Edit Destination Page + * Create/Edit Stream Page + */ + +import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; + +export const logsDestinationForm = { + /** + * Sets destination's label + * + * @param label - destination label to set + */ + setLabel: (label: string) => { + cy.findByLabelText('Destination Name') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Destination Name') + .clear(); + cy.focused().type(label); + }, + + /** + * Sets destination's host + * + * @param host - destination host to set + */ + setHost: (host: string) => { + cy.findByLabelText('Host') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Host') + .clear(); + cy.focused().type(host); + }, + + /** + * Sets destination's bucket name + * + * @param bucketName - destination bucket name to set + */ + setBucket: (bucketName: string) => { + cy.findByLabelText('Bucket') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Bucket') + .clear(); + cy.focused().type(bucketName); + }, + + /** + * Sets destination's Access Key ID + * + * @param accessKeyId - destination access key id to set + */ + setAccessKeyId: (accessKeyId: string) => { + cy.findByLabelText('Access Key ID') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Access Key ID') + .clear(); + cy.focused().type(accessKeyId); + }, + + /** + * Sets destination's Secret Access Key + * + * @param secretAccessKey - destination secret access key to set + */ + setSecretAccessKey: (secretAccessKey: string) => { + cy.findByLabelText('Secret Access Key') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Secret Access Key') + .clear(); + cy.focused().type(secretAccessKey); + }, + + /** + * Fills all form fields related to destination's details (LinodeObjectStorageDetails type) + * + * @param data - object with destination details of LinodeObjectStorageDetails type + */ + fillDestinationDetailsForm: (data: AkamaiObjectStorageDetails) => { + // Give Destination a host + logsDestinationForm.setHost(data.host); + + // Give Destination a bucket + logsDestinationForm.setBucket(data.bucket_name); + + // Give the Destination Access Key ID + logsDestinationForm.setAccessKeyId(data.access_key_id); + + // Give the Destination Secret Access Key + logsDestinationForm.setSecretAccessKey(data.access_key_secret); + }, +}; diff --git a/packages/manager/cypress/support/util/cleanup.ts b/packages/manager/cypress/support/util/cleanup.ts index 3dd61106092..11432ce0ff7 100644 --- a/packages/manager/cypress/support/util/cleanup.ts +++ b/packages/manager/cypress/support/util/cleanup.ts @@ -1,3 +1,4 @@ +import { deleteAllTestDestinations } from 'support/api/delivery'; import { deleteAllTestDomains } from 'support/api/domains'; import { cancelAllTestEntityTransfers } from 'support/api/entityTransfer'; import { deleteAllTestFirewalls } from 'support/api/firewalls'; @@ -17,6 +18,7 @@ import { deleteAllTestVolumes } from 'support/api/volumes'; /** Types of resources that can be cleaned up. */ export type CleanUpResource = + | 'destinations' | 'domains' | 'firewalls' | 'images' @@ -39,6 +41,7 @@ type CleanUpMap = { // Map `CleanUpResource` strings to the clean up functions they execute. const cleanUpMap: CleanUpMap = { + destinations: () => deleteAllTestDestinations(), domains: () => deleteAllTestDomains(), firewalls: () => deleteAllTestFirewalls(), images: () => deleteAllTestImages(), diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index a6556176da2..d075b7f42c7 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -13,6 +13,7 @@ export * from './cloudpulse/channels'; export * from './cloudpulse/services'; export * from './dashboards'; export * from './databases'; +export * from './delivery'; export * from './disk'; export * from './domain'; export * from './entityTransfers'; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx index c5d6537d8b3..9c1c6b0662b 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx @@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; -import { destinationFactory } from 'src/factories/delivery'; +import { destinationFactory } from 'src/factories'; import { DestinationActionMenu } from 'src/features/Delivery/Destinations/DestinationActionMenu'; import { renderWithTheme } from 'src/utilities/testHelpers'; 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 d0a24d6e7da..da08e80ad6d 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories/delivery'; +import { destinationFactory } from 'src/factories'; import { DestinationEdit } from 'src/features/Delivery/Destinations/DestinationForm/DestinationEdit'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx index 767f1acb704..1e11744834b 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories/delivery'; +import { destinationFactory } from 'src/factories'; import { DestinationsLanding } from 'src/features/Delivery/Destinations/DestinationsLanding'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.test.tsx index b928293fbf8..11b6614aaab 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamActionMenu.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamActionMenu.test.tsx @@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; -import { streamFactory } from 'src/factories/delivery'; +import { streamFactory } from 'src/factories'; import { StreamActionMenu } from 'src/features/Delivery/Streams/StreamActionMenu'; import { renderWithTheme } from 'src/utilities/testHelpers'; 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 9b54a76803e..e9a6efec52e 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 @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories/delivery'; +import { destinationFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx index 98026b543cf..54c4fbc14df 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories/delivery'; +import { destinationFactory } from 'src/factories'; import { StreamCreate } from 'src/features/Delivery/Streams/StreamForm/StreamCreate'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; 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 eb6c4c4130d..8dabe744d53 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory, streamFactory } from 'src/factories/delivery'; +import { destinationFactory, streamFactory } from 'src/factories'; import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; diff --git a/packages/manager/src/features/Delivery/Streams/StreamTableRow.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.test.tsx index 31e4a47f390..a30c4e6bc0f 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamTableRow.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { streamFactory } from 'src/factories/delivery'; +import { streamFactory } from 'src/factories'; import { StreamTableRow } from 'src/features/Delivery/Streams/StreamTableRow'; import { mockMatchMedia, diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx index f0eefa3c63c..0c9641e1178 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { streamFactory } from 'src/factories/delivery'; +import { streamFactory } from 'src/factories'; import { StreamsLanding } from 'src/features/Delivery/Streams/StreamsLanding'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts index a5729b908bf..50d8208811c 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts @@ -2,7 +2,7 @@ import { destinationType } from '@linode/api-v4'; import { DateTime } from 'luxon'; import { http } from 'msw'; -import { destinationFactory, streamFactory } from 'src/factories/delivery'; +import { destinationFactory, streamFactory } from 'src/factories'; import { mswDB } from 'src/mocks/indexedDB'; import { queueEvents } from 'src/mocks/utilities/events'; import { From 18ba87a95fc9e386fc32980435229eddbf4682cb Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Wed, 22 Oct 2025 11:03:45 +0200 Subject: [PATCH 10/39] feat: [UIE-9358] - IAM Parent/Child: Child Account - Default Entity Access (#12993) * feat: [UIE-9358] - IAM Parent/Child: Child Account - Default Entity Access * move logic to the hook * fix assigned default roles * test and filter out assigned roles * remove useMemo --- .../Defaults/DefaultEntityAccess.test.tsx | 60 +++++++++++++++ .../Roles/Defaults/DefaultEntityAccess.tsx | 5 +- .../AssignedEntitiesTable.test.tsx | 2 +- .../AssignedEntitiesTable.tsx | 71 ++++++++++++----- .../ChangeRoleForEntityDrawer.test.tsx | 2 +- .../ChangeRoleForEntityDrawer.tsx | 76 +++++++++++++++---- .../AssignedRolesTable/AssignedRolesTable.tsx | 1 + ...emoveAssignmentConfirmationDialog.test.tsx | 45 +++++++---- .../RemoveAssignmentConfirmationDialog.tsx | 64 ++++++++++++---- .../IAM/Users/UserEntities/UserEntities.tsx | 4 +- .../features/IAM/hooks/useDelegationRole.ts | 26 +++++++ .../mocks/presets/crud/handlers/delegation.ts | 4 +- packages/queries/src/iam/delegation.ts | 8 +- 13 files changed, 294 insertions(+), 74 deletions(-) create mode 100644 packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx rename packages/manager/src/features/IAM/{Users/UserEntities => Shared/AssignedEntitiesTable}/AssignedEntitiesTable.test.tsx (97%) rename packages/manager/src/features/IAM/{Users/UserEntities => Shared/AssignedEntitiesTable}/AssignedEntitiesTable.tsx (84%) rename packages/manager/src/features/IAM/{Users/UserEntities => Shared/AssignedEntitiesTable}/ChangeRoleForEntityDrawer.test.tsx (97%) rename packages/manager/src/features/IAM/{Users/UserEntities => Shared/AssignedEntitiesTable}/ChangeRoleForEntityDrawer.tsx (69%) diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx new file mode 100644 index 00000000000..43998897725 --- /dev/null +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DefaultEntityAccess } from './DefaultEntityAccess'; + +const queryMocks = vi.hoisted(() => ({ + useAllAccountEntities: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), + useGetDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi + .fn() + .mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }), +})); + +vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useGetDefaultDelegationAccessQuery: + queryMocks.useGetDefaultDelegationAccessQuery, + }; +}); + +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); + return { + ...actual, + useAllAccountEntities: queryMocks.useAllAccountEntities, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, + }; +}); + +describe('DefaultEntityAccess', () => { + it('should render', async () => { + renderWithTheme(); + + expect( + screen.getByText('Default Entity Access for Delegate Users') + ).toBeVisible(); + expect(screen.getByPlaceholderText('Search')).toBeVisible(); + expect(screen.getByPlaceholderText('All Entities')).toBeVisible(); + expect(screen.getByRole('table')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx index 39366c19e9b..6f5ce3ba3ca 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx @@ -1,10 +1,12 @@ import { Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; + export const DefaultEntityAccess = () => { return ( - + Default Entity Access for Delegate Users @@ -15,6 +17,7 @@ export const DefaultEntityAccess = () => { the assignment.
    + ); }; diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx similarity index 97% rename from packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx rename to packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx index d0c06967d7d..46ee9ab8a92 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx @@ -6,7 +6,7 @@ import { accountEntityFactory } from 'src/factories/accountEntities'; import { userRolesFactory } from 'src/factories/userRoles'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { AssignedEntitiesTable } from '../../Users/UserEntities/AssignedEntitiesTable'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; const queryMocks = vi.hoisted(() => ({ useAllAccountEntities: vi.fn().mockReturnValue({}), diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx similarity index 84% rename from packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx rename to packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx index fc922a54522..3f44e4b2ab1 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -1,7 +1,10 @@ -import { useUserRoles } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUserRoles, +} from '@linode/queries'; import { Select, Typography, useTheme } from '@linode/ui'; import Grid from '@mui/material/Grid'; -import { useParams, useSearch } from '@tanstack/react-router'; +import { useSearch } from '@tanstack/react-router'; import React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; @@ -20,19 +23,23 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useAllAccountEntities } from 'src/queries/entities/entities'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { usePermissions } from '../../hooks/usePermissions'; -import { ENTITIES_TABLE_PREFERENCE_KEY } from '../../Shared/constants'; -import { RemoveAssignmentConfirmationDialog } from '../../Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog'; +import { + addEntityNamesToRoles, + getSearchableFields, +} from '../../Users/UserEntities/utils'; +import { ENTITIES_TABLE_PREFERENCE_KEY } from '../constants'; +import { RemoveAssignmentConfirmationDialog } from '../RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog'; import { getFilteredRoles, getFormattedEntityType, groupAccountEntitiesByType, mapEntityTypesForSelect, -} from '../../Shared/utilities'; +} from '../utilities'; import { ChangeRoleForEntityDrawer } from './ChangeRoleForEntityDrawer'; -import { addEntityNamesToRoles, getSearchableFields } from './utils'; -import type { DrawerModes, EntitiesRole } from '../../Shared/types'; +import type { DrawerModes, EntitiesRole } from '../types'; import type { EntityType } from '@linode/api-v4'; import type { SelectOption } from '@linode/ui'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -44,13 +51,17 @@ const ALL_ENTITIES_OPTION: SelectOption = { type OrderByKeys = 'entity_name' | 'entity_type' | 'role_name'; -export const AssignedEntitiesTable = () => { - const { username } = useParams({ - from: '/iam/users/$username', - }); +interface Props { + username?: string; +} + +export const AssignedEntitiesTable = ({ username }: Props) => { const theme = useTheme(); const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { selectedRole: selectedRoleSearchParam } = useSearch({ strict: false, }); @@ -59,7 +70,9 @@ export const AssignedEntitiesTable = () => { const [orderBy, setOrderBy] = React.useState('entity_name'); const pagination = usePaginationV2({ - currentRoute: '/iam/users/$username/entities', + currentRoute: isDefaultDelegationRolesForChildAccount + ? '/iam/roles/defaults/entity-access' + : `/iam/users/$username/entities`, initialPage: 1, preferenceKey: ENTITIES_TABLE_PREFERENCE_KEY, }); @@ -93,10 +106,30 @@ export const AssignedEntitiesTable = () => { } = useAllAccountEntities({}); const { - data: assignedRoles, - error: assignedRolesError, - isLoading: assignedRolesLoading, - } = useUserRoles(username ?? ''); + data: assignedUserRoles, + error: assignedUserRolesError, + isLoading: assignedUserRolesLoading, + } = useUserRoles(username ?? '', !isDefaultDelegationRolesForChildAccount); + + const { + data: delegateDefaultRoles, + error: delegateDefaultRolesError, + isLoading: delegateDefaultRolesLoading, + } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + + const error = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRolesError + : assignedUserRolesError; + + const loading = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRolesLoading + : assignedUserRolesLoading; const { filterableOptions, roles } = React.useMemo(() => { if (!assignedRoles || !entities) { @@ -158,11 +191,11 @@ export const AssignedEntitiesTable = () => { }); const renderTableBody = () => { - if (entitiesLoading || assignedRolesLoading) { + if (entitiesLoading || loading) { return ; } - if (entitiesError || assignedRolesError) { + if (entitiesError || error) { return ( { onClose={() => setIsChangeRoleForEntityDrawerOpen(false)} open={isChangeRoleForEntityDrawerOpen} role={selectedRole} + username={username} /> handleRemoveAssignmentDialogClose()} open={isRemoveAssignmentDialogOpen} role={selectedRole} + username={username} /> {filteredRoles.length > PAGE_SIZES[0] && ( void; open: boolean; role: EntitiesRole | undefined; + username?: string; } export const ChangeRoleForEntityDrawer = ({ @@ -43,18 +48,33 @@ export const ChangeRoleForEntityDrawer = ({ onClose, open, role, + username, }: Props) => { const theme = useTheme(); - const { username } = useParams({ - from: '/iam/users/$username', - }); + + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const { data: accountRoles, isLoading: accountPermissionsLoading } = useAccountRoles(); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: assignedUserRoles } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + + const { data: delegateDefaultRoles } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + + const { mutateAsync: updateUserRoles } = useUserRolesMutation(username ?? ''); - const { mutateAsync: updateUserRoles } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultDelegationRoles } = + useUpdateDefaultDelegationAccessQuery(); // filtered roles by entity_type and access const allRoles = React.useMemo(() => { @@ -62,10 +82,30 @@ export const ChangeRoleForEntityDrawer = ({ return []; } - return getAllRoles(accountRoles).filter( - (el) => el.entity_type === role?.entity_type && el.access === role?.access - ); - }, [accountRoles, role]); + return getAllRoles(accountRoles).filter((el) => { + const matchesRoleContext = + el.entity_type === role?.entity_type && + el.access === role?.access && + el.value !== role?.role_name; + + // Exclude account roles already assigned to the user + if (isAccountRole(el)) { + return ( + !assignedRoles?.account_access.includes(el.value) && + matchesRoleContext + ); + } + // Exclude entity roles already assigned to the user + if (isEntityRole(el)) { + return ( + !assignedRoles?.entity_access.some((entity) => + entity.roles.includes(el.value) + ) && matchesRoleContext + ); + } + return true; + }); + }, [accountRoles, role, assignedRoles]); const { control, @@ -93,6 +133,10 @@ export const ChangeRoleForEntityDrawer = ({ return getRoleByName(accountRoles, selectedOptions.value); }, [selectedOptions, accountRoles]); + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultDelegationRoles + : updateUserRoles; + const onSubmit = async (data: { roleName: ExtendedEntityRole }) => { if (role?.role_name === data.roleName.label) { handleClose(); @@ -112,7 +156,7 @@ export const ChangeRoleForEntityDrawer = ({ newRole ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles!, entity_access: updatedEntityRoles, }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index cb20cbaac04..519e17e0f06 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -424,6 +424,7 @@ export const AssignedRolesTable = () => { onClose={() => setIsRemoveAssignmentDialogOpen(false)} open={isRemoveAssignmentDialogOpen} role={selectedRoleDetails} + username={username} /> {filteredAndSortedRolesCount > PAGE_SIZES[0] && ( ({ - useParams: vi.fn().mockReturnValue({}), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi + .fn() + .mockReturnValue({ isDefaultDelegationRolesForChildAccount: false }), +})); + +vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, })); vi.mock('@linode/queries', async () => { @@ -40,14 +47,6 @@ vi.mock('@linode/queries', async () => { }; }); -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useParams: queryMocks.useParams, - }; -}); - const mockDeleteUserRole = vi.fn(); vi.mock('@linode/api-v4', async () => { return { @@ -60,14 +59,10 @@ vi.mock('@linode/api-v4', async () => { }); describe('RemoveAssignmentConfirmationDialog', () => { - beforeEach(() => { - queryMocks.useParams.mockReturnValue({ - username: 'test_user', - }); - }); - it('should render', async () => { - renderWithTheme(); + renderWithTheme( + + ); const headerText = screen.getByText( 'Remove the Test entity from the firewall_admin role assignment?' @@ -131,4 +126,22 @@ describe('RemoveAssignmentConfirmationDialog', () => { }); }); }); + + it('should render when isDefaultDelegationRolesForChildAccount is true', async () => { + queryMocks.useIsDefaultDelegationRolesForChildAccount.mockReturnValue({ + isDefaultDelegationRolesForChildAccount: true, + }); + renderWithTheme(); + + const headerText = screen.getByText( + 'Remove the Test entity from the list?' + ); + expect(headerText).toBeVisible(); + + const paragraph = screen.getByText(/Delegated users won’t get the/i); + + expect(paragraph).toBeVisible(); + expect(paragraph).toHaveTextContent(mockRole.entity_name); + expect(paragraph).toHaveTextContent(mockRole.role_name); + }); }); diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx index 4531f0be1cf..6eff0ef3bbc 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx @@ -1,11 +1,16 @@ -import { useUserRoles, useUserRolesMutation } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUpdateDefaultDelegationAccessQuery, + useUserRoles, + useUserRolesMutation, +} from '@linode/queries'; import { ActionsPanel, Notice, Typography } from '@linode/ui'; -import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { deleteUserEntity, getErrorMessage } from '../utilities'; import type { EntitiesRole } from '../types'; @@ -15,11 +20,14 @@ interface Props { onSuccess?: () => void; open: boolean; role: EntitiesRole | undefined; + username?: string; } export const RemoveAssignmentConfirmationDialog = (props: Props) => { - const { onClose: _onClose, onSuccess, open, role } = props; - const { username } = useParams({ from: '/iam/users/$username' }); + const { onClose: _onClose, onSuccess, open, role, username } = props; + + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const { enqueueSnackbar } = useSnackbar(); @@ -28,15 +36,33 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { isPending, mutateAsync: updateUserRoles, reset, - } = useUserRolesMutation(username); + } = useUserRolesMutation(username ?? ''); + + const { mutateAsync: updateDefaultDelegationRoles } = + useUpdateDefaultDelegationAccessQuery(); + + const { data: assignedUserRoles } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: delegateDefaultRoles } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); const onClose = () => { reset(); // resets the error state of the useMutation _onClose(); }; + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultDelegationRoles + : updateUserRoles; + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + const onDelete = async () => { if (!role || !assignedRoles) return; @@ -49,7 +75,7 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { entity_type ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles, entity_access: updatedUserEntityRoles, }); @@ -81,14 +107,26 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { error={getErrorMessage(error)} onClose={onClose} open={open} - title={`Remove the ${role?.entity_name} entity from the ${role?.role_name} role assignment?`} + title={ + isDefaultDelegationRolesForChildAccount + ? `Remove the ${role?.entity_name} entity from the list?` + : `Remove the ${role?.entity_name} entity from the ${role?.role_name} role assignment?` + } > - - You’re about to remove the {role?.entity_name} entity - from the {role?.role_name} role for{' '} - {username}. This change will be applied immediately. - + {isDefaultDelegationRolesForChildAccount ? ( + + Delegated users won’t get the {role?.role_name} access on the{' '} + {role?.entity_name} entity by default. + + ) : ( + + You’re about to remove the {role?.entity_name}{' '} + entity from the {role?.role_name} role for{' '} + {username}. This change will be applied + immediately. + + )} ); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index b203ace51b3..0b469b44a89 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -13,12 +13,12 @@ import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { usePermissions } from '../../hooks/usePermissions'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; import { ERROR_STATE_TEXT, NO_ASSIGNED_ENTITIES_TEXT, } from '../../Shared/constants'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; -import { AssignedEntitiesTable } from './AssignedEntitiesTable'; export const UserEntities = () => { const theme = useTheme(); @@ -71,7 +71,7 @@ export const UserEntities = () => { View and manage entities attached to user's entity access roles. - + ) : ( { profileUserName: profile?.username, }; }; + +/** + * isDefaultDelegationRolesForChildAccount is true if: + * - IAM Delegation is enabled for the account + * - The current user is a child account + * - The current route includes '/iam/roles/defaults' + * + * This flag is used to determine if the component should show or fetch/update delegated default roles + * instead of regular user roles, and to adjust UI/logic for the delegate context. + */ +export const useIsDefaultDelegationRolesForChildAccount = () => { + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { isChildAccount } = useDelegationRole(); + const location = useLocation(); + + return { + isDefaultDelegationRolesForChildAccount: + (isIAMDelegationEnabled && + isChildAccount && + location.pathname.includes('/iam/roles/defaults')) ?? + false, + }; +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts index 5782db4237f..b7415ba38cb 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @@ -293,12 +293,12 @@ export const defaultDelegationAccess = () => [ ], entity_access: [ { - id: 12345678, + id: 1, type: 'linode' as const, roles: ['linode_contributor'], }, { - id: 45678901, + id: 1, type: 'firewall' as const, roles: ['firewall_admin'], }, diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts index b3905e70221..5fabd3900cf 100644 --- a/packages/queries/src/iam/delegation.ts +++ b/packages/queries/src/iam/delegation.ts @@ -331,11 +331,11 @@ export const useGenerateChildAccountTokenQuery = (): UseMutationResult< * - Audience: Child account administrators reviewing default delegate access. * - Data: IamUserRoles with `account_access` and `entity_access` for `GET /iam/delegation/default-role-permissions`. */ -export const useGetDefaultDelegationAccessQuery = (): UseQueryResult< - IamUserRoles, - APIError[] -> => { +export const useGetDefaultDelegationAccessQuery = ({ + enabled = true, +}): UseQueryResult => { return useQuery({ + enabled, ...delegationQueries.defaultAccess, }); }; From 42aabfc044df4fdb95c65a4e8a9cceda6863a706 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:56:18 +0200 Subject: [PATCH 11/39] fix: [UIE-8142] - IAM Roles Table styles and responsive enhancements (#12997) * initial commit - fix most issues * border fix * styles, just styles * fix test * Added changeset: IAM Roles Table styles and responsive enhancements * default order * clean up comment * cleanup --- .../pr-12997-fixed-1760991111727.md | 5 + packages/manager/package.json | 2 +- .../InlineMenuAction/InlineMenuAction.tsx | 12 +- .../IAM/Roles/RolesTable/RolesTable.test.tsx | 3 +- .../IAM/Roles/RolesTable/RolesTable.tsx | 126 +++++++++++++----- .../Roles/RolesTable/RolesTableActionMenu.tsx | 3 + .../RolesTable/RolesTableExpandedRow.tsx | 6 +- .../Shared/Permissions/Permissions.style.ts | 4 +- packages/manager/src/hooks/usePaginationV2.ts | 12 +- pnpm-lock.yaml | 18 +-- 10 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 packages/manager/.changeset/pr-12997-fixed-1760991111727.md diff --git a/packages/manager/.changeset/pr-12997-fixed-1760991111727.md b/packages/manager/.changeset/pr-12997-fixed-1760991111727.md new file mode 100644 index 00000000000..8578e885a8e --- /dev/null +++ b/packages/manager/.changeset/pr-12997-fixed-1760991111727.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM Roles Table styles and responsive enhancements ([#12997](https://github.com/linode/manager/pull/12997)) diff --git a/packages/manager/package.json b/packages/manager/package.json index bc5d01d7465..3c76404c170 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -45,7 +45,7 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", - "akamai-cds-react-components": "0.0.1-alpha.14", + "akamai-cds-react-components": "0.0.1-alpha.15", "algoliasearch": "^4.14.3", "axios": "~1.12.0", "braintree-web": "^3.92.2", diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index ab123c2dd13..2c5161a23ad 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -4,6 +4,8 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; +import type { SxProps } from '@mui/material/styles'; + interface InlineMenuActionProps { /** Required action text */ actionText: string; @@ -23,6 +25,8 @@ interface InlineMenuActionProps { loading?: boolean; /** Optional onClick handler */ onClick?: (e: React.MouseEvent) => void; + /** Optional custom styles */ + sx?: SxProps; /** Optional tooltip text for help icon */ tooltip?: string; /** Optional tooltip event handler for sending analytics */ @@ -38,6 +42,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { href, loading, onClick, + sx, tooltip, tooltipAnalyticsEvent, ...rest @@ -45,7 +50,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { if (href) { return ( - + {actionText} ); @@ -58,7 +63,10 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { disabled={disabled} loading={loading} onClick={onClick} - sx={buttonHeight !== undefined ? { height: buttonHeight } : {}} + sx={{ + ...sx, + height: buttonHeight !== undefined ? buttonHeight : undefined, + }} tooltipAnalyticsEvent={tooltipAnalyticsEvent} tooltipText={tooltip} {...rest} diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx index 5c469650dfb..ea0def51cc7 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; import { RolesTable } from './RolesTable'; @@ -59,6 +59,7 @@ beforeEach(() => { is_account_admin: true, }, }); + resizeScreenSize(1200); }); describe('RolesTable', () => { diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index f06fdaaa32d..29abe650844 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -1,5 +1,6 @@ -import { Button, Select, Typography } from '@linode/ui'; +import { Button, Hidden, Select, Typography } from '@linode/ui'; import { capitalizeAllWords } from '@linode/utilities'; +import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import { Pagination } from 'akamai-cds-react-components/Pagination'; @@ -35,24 +36,36 @@ import { import type { RoleView } from '../../Shared/types'; import type { SelectOption } from '@linode/ui'; import type { Order } from 'akamai-cds-react-components/Table'; + const ALL_ROLES_OPTION: SelectOption = { label: 'All Roles', value: 'all', }; +const COLUMN_WIDTHS = { + name: '26%', + access: '14%', + description: '38%', + actions: '10%', +}; + +const TABLE_CELL_BASE_STYLE = { + boxSizing: 'border-box' as const, +}; interface Props { roles?: RoleView[]; } 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 [filterableEntityType, setFilterableEntityType] = useState(ALL_ROLES_OPTION); const [sort, setSort] = useState< undefined | { column: string; order: Order } - >(undefined); + >({ column: 'name', order: 'asc' }); const [selectedRows, setSelectedRows] = useState([]); const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -61,6 +74,7 @@ export const RolesTable = ({ roles = [] }: Props) => { const pagination = usePaginationV2({ currentRoute: '/iam/roles', + defaultPageSize: DEFAULT_PAGE_SIZE, initialPage: 1, preferenceKey: ROLES_TABLE_PREFERENCE_KEY, }); @@ -201,6 +215,7 @@ export const RolesTable = ({ roles = [] }: Props) => { buttonType="primary" disabled={selectedRows.length === 0 || !isAccountAdmin} onClick={() => handleAssignSelectedRoles()} + sx={{ height: 34 }} tooltipText={ selectedRows.length === 0 ? 'You must select some roles to assign them.' @@ -212,35 +227,56 @@ export const RolesTable = ({ roles = [] }: Props) => { Assign Selected Roles - handleSelect(event, 'all')} - selectable selected={areAllSelected} > handleSort(event, 'name')} sortable sorted={sort?.column === 'name' ? sort.order : undefined} - style={{ minWidth: '26%' }} + style={{ + minWidth: COLUMN_WIDTHS.name, + ...TABLE_CELL_BASE_STYLE, + }} > Role + + handleSort(event, 'access')} + sortable + sorted={sort?.column === 'access' ? sort.order : undefined} + style={{ + minWidth: COLUMN_WIDTHS.access, + ...TABLE_CELL_BASE_STYLE, + }} + > + Role Type + + + + + Description + + handleSort(event, 'access')} - sortable - sorted={sort?.column === 'access' ? sort.order : undefined} - style={{ minWidth: '14%' }} - > - Role Type - - - Description - - + style={{ + minWidth: COLUMN_WIDTHS.actions, + ...TABLE_CELL_BASE_STYLE, + }} + /> @@ -262,27 +298,46 @@ export const RolesTable = ({ roles = [] }: Props) => { selected={selectedRows.includes(roleRow)} > {roleRow.name} - - {capitalizeAllWords(roleRow.access, '_')} - - - {roleRow.permissions.length ? ( - roleRow.description - ) : ( - - {getFacadeRoleDescription(roleRow)}{' '} - Learn more. - - )} - + + + {capitalizeAllWords(roleRow.access, '_')} + + + + + {roleRow.permissions.length ? ( + roleRow.description + ) : ( + + {getFacadeRoleDescription(roleRow)}{' '} + Learn more. + + )} + + { @@ -310,7 +370,7 @@ export const RolesTable = ({ roles = [] }: Props) => { onPageSizeChange={handlePageSizeChange} page={pagination.page} pageSize={pagination.pageSize} - style={{ borderTop: 0 }} + style={{ border: 0 }} /> )} diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx index 1e2faabcd0e..124e3bd4d31 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx @@ -18,6 +18,9 @@ export const RolesTableActionMenu = ({ buttonHeight={40} disabled={!canUpdateUserGrants} onClick={onClick} + sx={{ + whiteSpace: 'nowrap', + }} tooltip={ !canUpdateUserGrants ? 'You do not have permission to assign roles.' diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx index 3c2e8d2e6ee..a0bd698cd67 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx @@ -16,11 +16,7 @@ export const RolesTableExpandedRow = ({ permissions }: Props) => { return ( ({ font: theme.tokens.alias.Typography.Label.Bold.S, - marginBottom: theme.tokens.spacing.S4, + marginBottom: theme.tokens.spacing.S8, }) ); @@ -12,5 +12,5 @@ export const StyledPermissionItem = styled('span', { label: 'StyledPermissionItem', })(({ theme }) => ({ display: 'inline-block', - padding: `0px ${theme.tokens.spacing.S6} ${theme.tokens.spacing.S2}`, + padding: `0px ${theme.tokens.spacing.S6} ${theme.tokens.spacing.S4}`, })); diff --git a/packages/manager/src/hooks/usePaginationV2.ts b/packages/manager/src/hooks/usePaginationV2.ts index c8731b3296f..2a3590496b2 100644 --- a/packages/manager/src/hooks/usePaginationV2.ts +++ b/packages/manager/src/hooks/usePaginationV2.ts @@ -18,6 +18,13 @@ export interface UsePaginationV2Props { * The route to which the pagination params are applied. */ currentRoute: ToSubOptions['to']; + /** + * The default page size to use when no preference exists. + * @default MIN_PAGE_SIZE + * + * @warning For API-driven data, this should be set to the minimum page size supported by the API (25 to 500). + */ + defaultPageSize?: number; /** * The initial page pagination is set to - defaults to 1, it's unusual to set this. * @default 1 @@ -40,6 +47,7 @@ export interface UsePaginationV2Props { export const usePaginationV2 = ({ currentRoute, + defaultPageSize = MIN_PAGE_SIZE, initialPage = 1, preferenceKey, queryParamsPrefix, @@ -64,8 +72,8 @@ export const usePaginationV2 = ({ search[pageSizeKey as keyof TableSearchParams] || search.pageSize; const preferredPageSize = preferenceKey - ? (pageSizePreferences?.[preferenceKey] ?? MIN_PAGE_SIZE) - : MIN_PAGE_SIZE; + ? (pageSizePreferences?.[preferenceKey] ?? defaultPageSize) + : defaultPageSize; const page = searchParamPage ? Number(searchParamPage) : initialPage; const pageSize = searchParamPageSize diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b9a3e2becd..7bccdfcdc81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,8 +212,8 @@ importers: specifier: ^5.5.0 version: 5.5.0 akamai-cds-react-components: - specifier: 0.0.1-alpha.14 - version: 0.0.1-alpha.14(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 0.0.1-alpha.15 + version: 0.0.1-alpha.15(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) algoliasearch: specifier: ^4.14.3 version: 4.24.0 @@ -2979,14 +2979,14 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - akamai-cds-react-components@0.0.1-alpha.14: - resolution: {integrity: sha512-YDOlMSwtlueVQFXkyDiuX/Q1Uc/WXgn7jAtJHR6cFddOzCdvcF2kP0nO5G+iPqE0JBRLLPQXl1dYdHGcPgJMbw==} + akamai-cds-react-components@0.0.1-alpha.15: + resolution: {integrity: sha512-uJZLFjzxlV/hmGYiziIa7F6m55qSG0KMela7TLFbx+V1OFeAy71ULOGpOXGo9sU38GbUr8w4xw5YxLEcJPyRoA==} peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 - akamai-cds-web-components@0.0.1-alpha.14: - resolution: {integrity: sha512-PV/bh/FM00aJFDx+V9zkRLexxNcJVI615VXK1/kLbs6SZksgvUydQ5V82mrvNPjdQdhhA1H8trN92CF9KGpVzg==} + akamai-cds-web-components@0.0.1-alpha.15: + resolution: {integrity: sha512-Hb4P7tyqq7iE3lg/kSD1++jGuj9aFxbhcZ9MaQ9nWgFLbjm46EI0QQzALNUdXjwlaK5x+/uUri8IHveyiGzgtA==} peerDependencies: '@linode/design-language-system': ^4.0.0 @@ -8742,17 +8742,17 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - akamai-cds-react-components@0.0.1-alpha.14(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + akamai-cds-react-components@0.0.1-alpha.15(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@lit/react': 1.0.8(@types/react@19.1.6) - akamai-cds-web-components: 0.0.1-alpha.14(@linode/design-language-system@5.0.0) + akamai-cds-web-components: 0.0.1-alpha.15(@linode/design-language-system@5.0.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - '@linode/design-language-system' - '@types/react' - akamai-cds-web-components@0.0.1-alpha.14(@linode/design-language-system@5.0.0): + akamai-cds-web-components@0.0.1-alpha.15(@linode/design-language-system@5.0.0): dependencies: '@linode/design-language-system': 5.0.0 lit: 3.3.1 From 2c19a7acf1da1609bafea8f7b1a425aad1edfd87 Mon Sep 17 00:00:00 2001 From: tvijay-akamai <51293194+tvijay-akamai@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:02:19 +0530 Subject: [PATCH 12/39] change: [UIE-8745] - Table and Paginator web component integration in DBaaS (#12989) * change:[UIE-8745]-Table and paginator web component integration in dbaas * fixed cypress e2e test * UIE-8745 used tokens * UIE-8745 addressed review comments --------- Co-authored-by: hasyed-akamai Co-authored-by: Sakshi Tayal Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> --- .../core/databases/create-database.spec.ts | 4 +- .../DatabaseLanding/DatabaseLandingTable.tsx | 280 +++++++++++------- .../Databases/DatabaseLanding/DatabaseRow.tsx | 45 ++- 3 files changed, 203 insertions(+), 126 deletions(-) diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index 27faaca482b..d07ceb6fd0f 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -239,7 +239,7 @@ describe('create a database cluster, mocked data', () => { cy.findByText(databaseMock.label) .should('be.visible') - .closest('tr') + .closest('cds-table-row') .within(() => { cy.findByText(`${configuration.engine} v${configuration.version}`, { exact: false, @@ -260,7 +260,7 @@ describe('create a database cluster, mocked data', () => { cy.findByText(databaseMock.label) .should('be.visible') - .closest('tr') + .closest('cds-table-row') .within(() => { cy.findByText('Active').should('be.visible'); }); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index b6e566dc046..005412af11d 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -1,14 +1,16 @@ import { Hidden } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableHead, + TableHeaderCell, + TableRow, +} from 'akamai-cds-react-components/Table'; import React from 'react'; -import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableSortCell } from 'src/components/TableSortCell'; import AddAccessControlDrawer from 'src/features/Databases/DatabaseDetail/AddAccessControlDrawer'; import DatabaseSettingsDeleteClusterDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog'; import DatabaseSettingsResetPasswordDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog'; @@ -42,6 +44,7 @@ const DatabaseLandingTable = ({ orderBy, results, }: Props) => { + const theme = useTheme(); const { data: events } = useInProgressEvents(); const { isDatabasesV2GA } = useIsDatabasesEnabled(); @@ -52,6 +55,8 @@ const DatabaseLandingTable = ({ preferenceKey, queryParamsPrefix: dbPlatformType, }); + const PAGE_SIZES = [25, 50, 75, 100]; + const MIN_PAGE_SIZE = 25; const [selectedDatabase, setSelectedDatabase] = React.useState({} as DatabaseInstance); @@ -92,117 +97,164 @@ const DatabaseLandingTable = ({ return ( <> -
    - - - - Cluster Label - - +
    + + - Status - - {isNewDatabase && ( - - Plan - - )} - - + handleOrderChange('label', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'label' ? order : undefined} + style={{ + flex: '0 1 20.5%', + }} > - {isNewDatabase ? 'Nodes' : 'Configuration'} - - - - Engine - - - + + handleOrderChange('status', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'status' ? order : undefined} > - Region - - - - + {isNewDatabase && ( + + handleOrderChange('type', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'type' ? order : undefined} + > + Plan + + )} + + + handleOrderChange( + 'cluster_size', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'cluster_size' ? order : undefined} + > + {isNewDatabase ? 'Nodes' : 'Configuration'} + + + + handleOrderChange('engine', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'engine' ? order : undefined} > - Created - - - {isDatabasesV2GA && isNewDatabase && } - - - - {data?.map((database: DatabaseInstance) => ( - handleDelete(database), - handleManageAccessControls: () => - handleManageAccessControls(database), - handleResetPassword: () => handleResetPassword(database), - handleSuspend: () => handleSuspend(database), - }} - isNewDatabase={isNewDatabase} - key={database.id} - /> - ))} - {data?.length === 0 && ( - - )} - -
    - + Engine + + + + handleOrderChange( + 'region', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'region' ? order : undefined} + > + Region + + + + + handleOrderChange( + 'created', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'created' ? order : undefined} + > + Created + + + {isDatabasesV2GA && isNewDatabase && ( + + )} + + + + {data?.length === 0 ? ( + + ) : ( + data?.map((database: DatabaseInstance) => ( + handleDelete(database), + handleManageAccessControls: () => + handleManageAccessControls(database), + handleResetPassword: () => handleResetPassword(database), + handleSuspend: () => handleSuspend(database), + }} + isNewDatabase={isNewDatabase} + key={database.id} + /> + )) + )} + + + + {(results || 0) > MIN_PAGE_SIZE && ( + ) => + pagination.handlePageChange(Number(e.detail)) + } + onPageSizeChange={( + e: CustomEvent<{ page: number; pageSize: number }> + ) => pagination.handlePageSizeChange(Number(e.detail.pageSize))} + page={pagination.page} + pageSize={pagination.pageSize} + pageSizes={PAGE_SIZES} + style={{ + borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderTop: 0, + }} + /> + )} + {isNewDatabase && ( <> diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index ba13caa0258..6e68f13784c 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -3,14 +3,12 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; -import { Chip } from '@linode/ui'; -import { Hidden } from '@linode/ui'; +import { Chip, Hidden, styled } from '@linode/ui'; import { formatStorageUnits } from '@linode/utilities'; +import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { TableCell } from 'src/components/TableCell'; -import { TableRow } from 'src/components/TableRow'; import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { DatabaseActionMenu } from 'src/features/Databases/DatabaseLanding/DatabaseActionMenu'; @@ -36,6 +34,24 @@ interface Props { isNewDatabase?: boolean; } +const DatabaseActionMenuStyledWrapper = styled(TableCell, { + label: 'DatabaseActionMenuStyledWrapper', +})(({ theme }) => ({ + justifyContent: 'flex-end', + display: 'flex', + alignItems: 'center', + maxWidth: 40, + '& button': { + padding: 0, + color: theme.tokens.alias.Content.Icon.Primary.Default, + backgroundColor: 'transparent', + }, + '& button:hover': { + backgroundColor: 'transparent', + color: theme.tokens.alias.Content.Icon.Primary.Hover, + }, +})); + export const DatabaseRow = ({ database, events, @@ -80,21 +96,30 @@ export const DatabaseRow = ({ ({ borderColor: theme.color.green, mx: 2 })} + sx={(theme) => ({ borderColor: theme.color.green, mx: 0, my: 0 })} variant="outlined" /> ); return ( - - + + {isDatabasesV2GA && isLinkInactive ? ( label ) : ( {label} )} - + {isNewDatabase && {formattedPlan}} @@ -123,7 +148,7 @@ export const DatabaseRow = ({ {isDatabasesV2GA && isNewDatabase && ( - + - + )} ); From 617f2d0cbb95018fcc4ed8d17046a99ba6473eb5 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Fri, 24 Oct 2025 13:31:22 +0530 Subject: [PATCH 13/39] =?UTF-8?q?upcoming:=20[UIE-9377]=20=E2=80=93=20Supp?= =?UTF-8?q?ort=20Private=20Image=20Sharing=20feature=20flag=20(#12992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * upcoming: [UIE-9377] – Support Private Image Sharing feature flag * Added changeset: Add support for `privateImageSharing` feature flag for Private Image Sharing feature --- ...r-12992-upcoming-features-1760600896958.md | 5 +++ .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 1 + .../src/features/Images/utils.test.tsx | 34 ++++++++++++++++++- packages/manager/src/features/Images/utils.ts | 16 +++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md diff --git a/packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md b/packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md new file mode 100644 index 00000000000..2c223a26cb9 --- /dev/null +++ b/packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add support for `privateImageSharing` feature flag for Private Image Sharing feature ([#12992](https://github.com/linode/manager/pull/12992)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index eebdafe4010..dd1bda8e898 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -40,6 +40,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'nodebalancerVpc', label: 'NodeBalancer-VPC Integration' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, + { flag: 'privateImageSharing', label: 'Private Image Sharing' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 1a87e7429f1..01b88c8d055 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -194,6 +194,7 @@ export interface Flags { objectStorageGen2: BaseFeatureFlag; objMultiCluster: boolean; objSummaryPage: boolean; + privateImageSharing: boolean; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index 875078cb129..bdfc503faab 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -1,8 +1,14 @@ import { linodeFactory } from '@linode/utilities'; +import { renderHook, waitFor } from '@testing-library/react'; import { eventFactory, imageFactory } from 'src/factories'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; -import { getEventsForImages, getImageLabelForLinode } from './utils'; +import { + getEventsForImages, + getImageLabelForLinode, + useIsPrivateImageSharingEnabled, +} from './utils'; describe('getImageLabelForLinode', () => { it('handles finding an image and getting the label', () => { @@ -57,3 +63,29 @@ describe('getEventsForImages', () => { }); }); }); + +describe('useIsPrivateImageSharingEnabled', () => { + it('returns true if the feature is enabled', async () => { + const options = { flags: { privateImageSharing: true } }; + + const { result } = renderHook(() => useIsPrivateImageSharingEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isPrivateImageSharingEnabled).toBe(true); + }); + }); + + it('returns false if the feature is NOT enabled', async () => { + const options = { flags: { privateImageSharing: false } }; + + const { result } = renderHook(() => useIsPrivateImageSharingEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isPrivateImageSharingEnabled).toBe(false); + }); + }); +}); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index 7d1bf6594bd..dc491d18c10 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -1,6 +1,7 @@ import { useRegionsQuery } from '@linode/queries'; import { DISALLOWED_IMAGE_REGIONS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; import type { Event, Image, Linode } from '@linode/api-v4'; @@ -39,3 +40,18 @@ export const useRegionsThatSupportImageStorage = () => { ) ?? [], }; }; + +/** + * Returns whether or not features related to the Private Image Sharing project + * should be enabled. + * + * Currently, this just uses the `privateImageSharing` feature flag as a source of truth, + * but will eventually also look at account capabilities. + */ + +export const useIsPrivateImageSharingEnabled = () => { + const flags = useFlags(); + + // @TODO Private Image Sharing: check for customer tag/account capability when it exists + return { isPrivateImageSharingEnabled: flags.privateImageSharing ?? false }; +}; From ebcfd5db2874242bd0a52744cd849ef3ec8dbc3f Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 24 Oct 2025 11:09:46 +0200 Subject: [PATCH 14/39] feat: [UIE-9388] - IAM Parent/Child: redirect route (#13007) * feat: [UIE-9388] - IAM Parent/Child: redirect route * Added changeset: IAM Parent/Child: redirect route /delegations for non-parent users --- .../.changeset/pr-13007-changed-1761211855914.md | 5 +++++ packages/manager/src/features/IAM/IAMLanding.tsx | 6 +++--- packages/manager/src/routes/IAM/index.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13007-changed-1761211855914.md diff --git a/packages/manager/.changeset/pr-13007-changed-1761211855914.md b/packages/manager/.changeset/pr-13007-changed-1761211855914.md new file mode 100644 index 00000000000..81bf8e6c59f --- /dev/null +++ b/packages/manager/.changeset/pr-13007-changed-1761211855914.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM Parent/Child: redirect route /delegations for non-parent users ([#13007](https://github.com/linode/manager/pull/13007)) diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 0a1ec1b20cb..822ee434d84 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -6,17 +6,17 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; -import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useDelegationRole } from './hooks/useDelegationRole'; +import { useIsIAMDelegationEnabled } from './hooks/useIsIAMEnabled'; import { IAM_DOCS_LINK, ROLES_LEARN_MORE_LINK } from './Shared/constants'; export const IdentityAccessLanding = React.memo(() => { const location = useLocation(); const navigate = useNavigate(); - const flags = useFlags(); const { isParentAccount } = useDelegationRole(); + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { tabs, tabIndex, handleTabChange } = useTabs([ { @@ -28,7 +28,7 @@ export const IdentityAccessLanding = React.memo(() => { title: 'Roles', }, { - hide: !flags.iamDelegation?.enabled || !isParentAccount, + hide: !isIAMDelegationEnabled || !isParentAccount, to: `/iam/delegations`, title: 'Account Delegations', }, diff --git a/packages/manager/src/routes/IAM/index.ts b/packages/manager/src/routes/IAM/index.ts index 22ef887cf77..a0a5dc32178 100644 --- a/packages/manager/src/routes/IAM/index.ts +++ b/packages/manager/src/routes/IAM/index.ts @@ -307,6 +307,19 @@ const iamUserNameEntitiesRoute = createRoute({ const iamUserNameDelegationsRoute = createRoute({ getParentRoute: () => iamUserNameRoute, path: 'delegations', + beforeLoad: async ({ context, params }) => { + const isDelegationEnabled = context?.flags?.iamDelegation?.enabled; + const profile = context?.profile; + const userType = profile?.user_type; + const { username } = params; + + if (userType !== 'parent' || !isDelegationEnabled) { + throw redirect({ + to: '/iam/users/$username/details', + params: { username }, + }); + } + }, }).lazy(() => import( 'src/features/IAM/Users/UserDelegations/userDelegationsLazyRoute' From 500d4d87850c520f6cccd0a89411c95a050e5c08 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:15:11 +0200 Subject: [PATCH 15/39] feat: [UIE-9389], [UIE-9448], [UIE-9449] - IAM: Parent Account UI fix (#13011) * feat: [UIE-9389], [UIE-9448], [UIE-9449] - IAM: Parent Account UI fix * Added changeset: IAM Delegation: Parent Account UI fix --- .../pr-13011-upcoming-features-1761229143134.md | 5 +++++ .../IAM/Delegations/AccountDelegationsTable.tsx | 4 ++-- .../IAM/Delegations/AccountDelegationsTableRow.tsx | 12 +++++++++++- .../IAM/Delegations/UpdateDelegationsDrawer.tsx | 5 ++++- 4 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md diff --git a/packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md b/packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md new file mode 100644 index 00000000000..4e81d563365 --- /dev/null +++ b/packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +IAM Delegation: Parent Account UI fix ([#13011](https://github.com/linode/manager/pull/13011)) diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx index a703934cf39..14ff01ce355 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx @@ -56,12 +56,12 @@ export const AccountDelegationsTable = ({ Account Delegate Users - + diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx index 13116941df4..0c4350b4ecb 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx @@ -33,7 +33,17 @@ export const AccountDelegationsTableRow = ({ delegation, index }: Props) => { key={`delegation-${delegation.euuid}-${index}`} > - {delegation.company} + + {delegation.company} + ({ diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx index 698ea7cd42e..2d229dbf69e 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx @@ -97,7 +97,7 @@ export const UpdateDelegationsDrawer = ({ euuid: delegation.euuid, users: usersList, }); - enqueueSnackbar(`Delegate users updated.`, { variant: 'success' }); + enqueueSnackbar(`Delegation updated`, { variant: 'success' }); handleClose(); } catch (errors) { for (const error of errors) { @@ -131,6 +131,9 @@ export const UpdateDelegationsDrawer = ({ Update delegation for {delegation.company}: From e12d77212350b780a9cf58fd26d280a6ad21df74 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 24 Oct 2025 14:25:55 +0200 Subject: [PATCH 16/39] feat: [UIE-9454, UIE-9453] - IAM Parent/Child: fix spacing and add notification (#13013) * feat: [UIE-9454, UIE-9453] - IAM Parent/Child: fix spacing and add notification * Added changeset: IAM Parent/Child: fix spacing and add notification --- packages/manager/.changeset/pr-13013-fixed-1761296675184.md | 5 +++++ .../src/features/IAM/Roles/Defaults/DefaultsLanding.tsx | 1 + .../AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx | 6 ++++++ 3 files changed, 12 insertions(+) create mode 100644 packages/manager/.changeset/pr-13013-fixed-1761296675184.md diff --git a/packages/manager/.changeset/pr-13013-fixed-1761296675184.md b/packages/manager/.changeset/pr-13013-fixed-1761296675184.md new file mode 100644 index 00000000000..7bbef8c3be3 --- /dev/null +++ b/packages/manager/.changeset/pr-13013-fixed-1761296675184.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM Parent/Child: fix spacing and add notification ([#13013](https://github.com/linode/manager/pull/13013)) diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx index f750e026226..7ed5c0b2db9 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultsLanding.tsx @@ -33,6 +33,7 @@ export const DefaultsLanding = () => { breadcrumbProps={{ pathname: '/iam/roles/defaults', }} + spacingBottom={4} title="Default Roles for Delegate Users" /> diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx index 38803836087..9d76decc962 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx @@ -13,6 +13,7 @@ import { Typography, } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; +import { useSnackbar } from 'notistack'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -51,6 +52,7 @@ export const ChangeRoleForEntityDrawer = ({ username, }: Props) => { const theme = useTheme(); + const { enqueueSnackbar } = useSnackbar(); const { isDefaultDelegationRolesForChildAccount } = useIsDefaultDelegationRolesForChildAccount(); @@ -161,6 +163,10 @@ export const ChangeRoleForEntityDrawer = ({ entity_access: updatedEntityRoles, }); + enqueueSnackbar(`Role changed`, { + variant: 'success', + }); + handleClose(); } catch (errors) { for (const error of errors) { From a080bd87428eab14583e3a416cb397aade256aed Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Mon, 27 Oct 2025 11:45:39 +0530 Subject: [PATCH 17/39] upcoming: [UIE-9343] - Add API endpoints and types for `/v4/images/sharegroups/members` and `/v4/images/sharegroups/tokens` (#12984) * upcoming: [UIE-9311] - Add API endpoints and types for `/v4/images/sharegroups` * Add validation schema * upcoming: [UIE-9343] - Add API endpoints and types for `/v4beta/images/sharegroups/members` and `/v4beta/images/sharegroups/tokens` * rename sharegroup.ts * add named arguments for functions with more than 2 parameters * pr split + validation schema separation * PR feedback @pmakode-akamai * Added changeset: Add endpoints for `/v4/images/sharegroups/members` and `/v4/images/sharegroups/tokens` * Added changeset: Add validation schemas for creating and updating Sharegroup Members and Tokens --- ...r-12984-upcoming-features-1761308732683.md | 5 + packages/api-v4/src/images/sharegroup.ts | 237 ++++++++++++++++++ packages/api-v4/src/images/types.ts | 165 ++++++++++++ ...r-12984-upcoming-features-1761308818735.md | 5 + packages/validation/src/images.schema.ts | 20 ++ 5 files changed, 432 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md create mode 100644 packages/api-v4/src/images/sharegroup.ts create mode 100644 packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md diff --git a/packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md b/packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md new file mode 100644 index 00000000000..588b2a0b406 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add endpoints for `/v4/images/sharegroups/members` and `/v4/images/sharegroups/tokens` ([#12984](https://github.com/linode/manager/pull/12984)) diff --git a/packages/api-v4/src/images/sharegroup.ts b/packages/api-v4/src/images/sharegroup.ts new file mode 100644 index 00000000000..811ef234bd5 --- /dev/null +++ b/packages/api-v4/src/images/sharegroup.ts @@ -0,0 +1,237 @@ +import { + addSharegroupMemberSchema, + generateSharegroupTokenSchema, + updateSharegroupMemberSchema, + updateSharegroupTokenSchema, +} from '@linode/validation/lib/images.schema'; + +import { BETA_API_ROOT } from '../constants'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { + AddSharegroupMemberPayload, + GenerateSharegroupTokenPayload, + Image, + Sharegroup, + SharegroupMember, + SharegroupToken, + UpdateSharegroupMemberPayload, +} from './types'; + +/** + * Add Member to the Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to add member + * @param data {AddSharegroupMemberPayload} the Member details + */ +export const addMembersToSharegroup = ( + sharegroupId: number, + data: AddSharegroupMemberPayload, +) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images`, + ), + setMethod('POST'), + setData(data, addSharegroupMemberSchema), + ); +}; + +/** + * Generate user token for the Sharegroup + * + * @param data {GenerateSharegroupTokenPayload} the token details + */ +export const generateSharegroupToken = ( + data: GenerateSharegroupTokenPayload, +) => { + return Request( + setURL(`${BETA_API_ROOT}/images/sharegroups/tokens`), + setMethod('POST'), + setData(data, generateSharegroupTokenSchema), + ); +}; + +/** + * Get details of the Sharegroup the token has been accepted into + * + * @param token_uuid {string} Token UUID of the user + */ +export const getSharegroupFromToken = (token_uuid: string) => { + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}/sharegroup`, + ), + setMethod('GET'), + ); +}; + +/** + * Get a paginated list of Sharegroup Images the token has been accepted into + * + * @param token_uuid {string} Token UUID of the user + */ +export const getSharegroupImagesFromToken = ( + token_uuid: string, + params: Params = {}, + filters: Filter = {}, +) => { + Request>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}/sharegroups/images`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); +}; + +/** + * Get a paginated list of members part of the Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + */ +export const getSharegroupMembers = ( + sharegroupId: string, + params: Params = {}, + filters: Filter = {}, +) => { + Request>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); +}; + +/** + * Get member details of a user from the Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + * @param token_uuid {string} Token UUID of the user to look up + */ +export const getSharegroupMember = ( + sharegroupId: string, + token_uuid: string, +) => { + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members/${encodeURIComponent(token_uuid)}`, + ), + setMethod('GET'), + ); +}; + +/** + * Returns a paginated list of tokens created by the user + */ +export const getUserSharegroupTokens = ( + params: Params = {}, + filters: Filter = {}, +) => { + Request>( + setURL(`${BETA_API_ROOT}/images/sharegroups/tokens`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); +}; + +/** + * Get details about a specific token created by the user + * + * @param token_uuid Token UUID of the user to look up + */ +export const getUserSharegroupToken = (token_uuid: string) => { + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + ), + setMethod('GET'), + ); +}; + +/** + * Update a user token's label + * + * @param token_uuid {string} token UUID of the user + * @param data {UpdateSharegroupMemberPayload} the updated label + */ +export const updateSharegroupToken = ( + token_uuid: string, + data: UpdateSharegroupMemberPayload, +) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + ), + setMethod('PUT'), + setData(data, updateSharegroupTokenSchema), + ); +}; + +/** + * Update a Sharegroup member's label + * + * @param token_uuid {string} token UUID of the user + * @param data {UpdateSharegroupMemberPayload} the updated label + */ +interface UpdateSharegroupMember { + data: UpdateSharegroupMemberPayload; + sharegroupId: string; + token_uuid: string; +} + +export const updateSharegroupMember = ({ + sharegroupId, + token_uuid, + data, +}: UpdateSharegroupMember) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members/${encodeURIComponent(token_uuid)}`, + ), + setMethod('PUT'), + setData(data, updateSharegroupMemberSchema), + ); +}; + +/** + * Delete a user token + * + * @param token_uuid {string} Token UUID of the user to delete + */ +export const deleteSharegroupToken = (token_uuid: string) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + ), + setMethod('DELETE'), + ); +}; + +/** + * Delete a sharegroup Member + * + * @param token_uuid {string} Token UUID of the member to delete + */ +export const deleteSharegroupMember = ( + sharegroupId: string, + token_uuid: string, +) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members/${encodeURIComponent(token_uuid)}`, + ), + setMethod('DELETE'), + ); +}; diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index 8eb4c68fbe9..f77301675e7 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,6 +4,8 @@ export type ImageCapabilities = 'cloud-init' | 'distributed-sites'; type ImageType = 'automatic' | 'manual'; +type SharegroupMemberStatus = 'active' | 'revoked'; + export type ImageRegionStatus = | 'available' | 'creating' @@ -18,6 +20,19 @@ export interface ImageRegion { status: ImageRegionStatus; } +export interface ImageSharingData { + shared_by: null | { + sharegroup_id: number; + sharegroup_label: string; + sharegroup_uuid: string; + source_image_id: number; + }; + shared_with: null | { + sharegroup_count: number; + sharegroup_list_url: string; + }; +} + export interface Image { /** * A list of the capabilities of this image. @@ -59,11 +74,21 @@ export interface Image { */ id: string; + /** + * Image sharing attributes for private and shared images. + */ + image_sharing?: ImageSharingData; + /** * Whether this image is marked for public distribution. */ is_public: boolean; + /** + * Whether this image has a shared copy. + */ + is_shared?: boolean; + /** * A short description of this image. */ @@ -157,3 +182,143 @@ export interface UpdateImageRegionsPayload { */ regions: string[]; } + +export interface Sharegroup { + /** + * The timestamp of when the Sharegroup was created + */ + created: string; + /** + * A detailed description for the Sharegroup + */ + description: string; + /** + * The timestamp of when the Sharegroup would expire + */ + expiry?: string; + /** + * The ID of the this Sharegroup. + */ + id: number; + /** + * The number of images shared in the Sharegroup + */ + images_count?: number; + /** + * A boolean that indicates if the Sharegroup is suspended + */ + is_suspended: boolean; + /** + * A short title for the Sharegroup + */ + label: string; + /** + * The number of members present in the Sharegroup + */ + members_count?: number; + /** + * The timestamp of when the Sharegroup was last updated + */ + updated: string; + /** + * A unique identifier for the sharegroup which can be used to generate member tokens + */ + uuid: string; +} + +export interface AddSharegroupMemberPayload { + /** + * The title given to the user in the sharegroup + */ + label: string; + /** + * The user token shared by the user to join the sharegroup + */ + token: string; +} + +export type UpdateSharegroupMemberPayload = Omit< + AddSharegroupMemberPayload, + 'token' +>; + +export interface SharegroupMember { + /** + * The timestamp of when the member was added to the sharegroup + */ + created: string; + /** + * The timestamp of when the member's token expires + */ + expiry: string; + /** + * The title given to the user in the sharegroup + */ + label: string; + /** + * The status of the member in the current sharegroup + */ + status: SharegroupMemberStatus; + /** + * A unique identifier for member tokens + */ + token_uuid: string; + /** + * The timestamp of when the member's information was last updated + */ + updated: string; +} + +export interface GenerateSharegroupTokenPayload { + /** + * The title given to the user in the sharegroup + */ + label?: string; + /** + * The sharegroup UUID for which a user token will be generated + */ + valid_for_sharegroup_uuid: string; +} + +export interface SharegroupToken { + /** + * The timestamp of when the token was created + */ + created: string; + /** + * The timestamp of when the token will expire + */ + expiry: string; + /** + * The title given to the user in the sharegroup + */ + label: string; + /** + * The sharegroup label this token is created for + */ + sharegroup_label: string; + /** + * The sharegroup UUID the token is created for + */ + sharegroup_uuid: string; + /** + * The current status of this token + */ + status: string; + /** + * A unique member token to join the sharegroup + */ + token: string; + /** + * A unique identifier for each token generated + */ + token_uuid: string; + /** + * The timestamp of when the token was last updated + */ + updated: string; + /** + * The sharegroup UUID the token is valid for + */ + valid_for_sharegroup_uuid: string; +} diff --git a/packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md b/packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md new file mode 100644 index 00000000000..98a1993c429 --- /dev/null +++ b/packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Add validation schemas for creating and updating Sharegroup Members and Tokens ([#12984](https://github.com/linode/manager/pull/12984)) diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index 9ac35a1c027..e5e84bae480 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -39,3 +39,23 @@ export const updateImageRegionsSchema = object({ .required('Regions are required.') .min(1, 'Must specify at least one region.'), }); + +export const addSharegroupMemberSchema = object({ + token: string().required('Token is required.'), + label: labelSchema.required('Label is required.'), +}); + +export const generateSharegroupTokenSchema = object({ + label: labelSchema.optional(), + valid_for_sharegroup_uuid: boolean().required( + 'Valid sharegroup UUID required.', + ), +}); + +export const updateSharegroupTokenSchema = object({ + label: labelSchema.required('Label is required.'), +}); + +export const updateSharegroupMemberSchema = object({ + label: labelSchema.required('Label is required.'), +}); From 7a831f311841e9a1c43d4b3f5779c2ab2ae5c98f Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Mon, 27 Oct 2025 13:56:06 +0530 Subject: [PATCH 18/39] upcoming: [UIE-9343] - Add API endpoints and types for `/v4/images/sharegroups` and `/v4/images/sharegroups/images` (#12985) * upcoming: [UIE-9311] - Add API endpoints and types for `/v4/images/sharegroups` * Add validation schema * upcoming: [UIE-9343] - Add API endpoints and types for `/v4beta/images/sharegroups` and `/v4beta/images/sharegroups/images` * update sharegroup.ts filename * add named arguments for functions with more than 2 parameters * PR feedback @dwiley-akamai * remove string field constraints * Added changeset: Add endpoints for `/v4/images/sharegroups` and `/v4/images/sharegroups/images` * Added changeset: Add validation schemas for creating and updating Sharegroups and Sharegroup Images --- ...r-12985-upcoming-features-1761307537636.md | 5 + packages/api-v4/src/images/sharegroup.ts | 205 ++++++++++++++++-- packages/api-v4/src/images/types.ts | 43 ++++ .../LinodeInterfaces/VPCIPv4Address.tsx | 3 +- ...r-12985-upcoming-features-1761308567524.md | 5 + packages/validation/src/images.schema.ts | 34 ++- 6 files changed, 276 insertions(+), 19 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md create mode 100644 packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md diff --git a/packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md b/packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md new file mode 100644 index 00000000000..6295fd8efbe --- /dev/null +++ b/packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add endpoints for `/v4/images/sharegroups` and `/v4/images/sharegroups/images` ([#12985](https://github.com/linode/manager/pull/12985)) diff --git a/packages/api-v4/src/images/sharegroup.ts b/packages/api-v4/src/images/sharegroup.ts index 811ef234bd5..a79e6c9b988 100644 --- a/packages/api-v4/src/images/sharegroup.ts +++ b/packages/api-v4/src/images/sharegroup.ts @@ -1,7 +1,11 @@ import { + addSharegroupImagesSchema, addSharegroupMemberSchema, + createSharegroupSchema, generateSharegroupTokenSchema, + updateSharegroupImageSchema, updateSharegroupMemberSchema, + updateSharegroupSchema, updateSharegroupTokenSchema, } from '@linode/validation/lib/images.schema'; @@ -16,15 +20,51 @@ import Request, { import type { Filter, ResourcePage as Page, Params } from '../types'; import type { + AddSharegroupImagesPayload, AddSharegroupMemberPayload, + CreateSharegroupPayload, GenerateSharegroupTokenPayload, Image, Sharegroup, SharegroupMember, SharegroupToken, + UpdateSharegroupImagePayload, UpdateSharegroupMemberPayload, + UpdateSharegroupPayload, } from './types'; +/** + * Create a Image Sharegroup. + * + * @param data { createSharegroupPayload } the sharegroup details + */ +export const createSharegroup = (data: CreateSharegroupPayload) => { + return Request( + setURL(`${BETA_API_ROOT}/images/sharegroups`), + setMethod('POST'), + setData(data, createSharegroupSchema), + ); +}; + +/** + * Add Images to the Sharegroup + * + * @param sharegroupId { string } ID of the sharegroup to add images + * @param data { AddSharegroupImagesPayload } the image details + */ +export const addImagesToSharegroup = ( + sharegroupId: number, + data: AddSharegroupImagesPayload, +) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images`, + ), + setMethod('POST'), + setData(data, addSharegroupImagesSchema), + ); +}; + /** * Add Member to the Sharegroup * @@ -37,7 +77,7 @@ export const addMembersToSharegroup = ( ) => { return Request( setURL( - `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images`, + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members`, ), setMethod('POST'), setData(data, addSharegroupMemberSchema), @@ -59,6 +99,47 @@ export const generateSharegroupToken = ( ); }; +/** + * Returns a paginated list of Sharegroups + */ +export const getSharegroups = (params: Params = {}, filters: Filter = {}) => + Request>( + setURL(`${BETA_API_ROOT}/images/sharegroups`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * Lists all the sharegroups a given private image is present in. + * + * @param imageId { string } ID of the Image to look up. + */ +export const getSharegroupsFromImage = ( + imageId: string, + params: Params = {}, + filters: Filter = {}, +) => + Request>( + setURL(`${BETA_API_ROOT}/images/${imageId}/sharegroups`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * Get information about a single Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + */ +export const getSharegroup = (sharegroupId: string) => + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}`, + ), + setMethod('GET'), + ); + /** * Get details of the Sharegroup the token has been accepted into * @@ -73,6 +154,25 @@ export const getSharegroupFromToken = (token_uuid: string) => { ); }; +/** + * Get a paginated list of Images present in a Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + */ +export const getSharegroupImages = ( + sharegroupId: string, + params: Params = {}, + filters: Filter = {}, +) => + Request>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + /** * Get a paginated list of Sharegroup Images the token has been accepted into * @@ -161,21 +261,47 @@ export const getUserSharegroupToken = (token_uuid: string) => { }; /** - * Update a user token's label + * Update a Sharegroup. * - * @param token_uuid {string} token UUID of the user - * @param data {UpdateSharegroupMemberPayload} the updated label + * @param sharegroupId {string} ID of the Sharegroup to update + * @param data { updateSharegroupPayload } the sharegroup details */ -export const updateSharegroupToken = ( - token_uuid: string, - data: UpdateSharegroupMemberPayload, +export const updateSharegroup = ( + sharegroupId: string, + data: UpdateSharegroupPayload, ) => { - return Request( + return Request( setURL( - `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}`, ), setMethod('PUT'), - setData(data, updateSharegroupTokenSchema), + setData(data, updateSharegroupSchema), + ); +}; + +/** + * Update an Image in a Sharegroup. + * + * @param sharegroupId {string} ID of the Sharegroup the image belongs to + * @param imageId {string} ID of the Image to update + * @param data { UpdateSharegroupImagePayload } the updated image details + */ +interface UpdateSharegroupImage { + data: UpdateSharegroupImagePayload; + imageId: string; + sharegroupId: string; +} +export const updateSharegroupImage = ({ + sharegroupId, + imageId, + data, +}: UpdateSharegroupImage) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroup/${encodeURIComponent(sharegroupId)}/images/${encodeURIComponent(imageId)}}`, + ), + setMethod('PUT'), + setData(data, updateSharegroupImageSchema), ); }; @@ -206,15 +332,52 @@ export const updateSharegroupMember = ({ }; /** - * Delete a user token + * Update a user token's label * - * @param token_uuid {string} Token UUID of the user to delete + * @param token_uuid {string} token UUID of the user + * @param data {UpdateSharegroupMemberPayload} the updated label */ -export const deleteSharegroupToken = (token_uuid: string) => { - return Request<{}>( +export const updateSharegroupToken = ( + token_uuid: string, + data: UpdateSharegroupMemberPayload, +) => { + return Request( setURL( `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, ), + setMethod('PUT'), + setData(data, updateSharegroupTokenSchema), + ); +}; + +/** + * Delete a sharegroup + * + * @param sharegroupId {string} ID of the sharegroup to delete + */ +export const deleteSharegroup = (sharegroupId: string) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}`, + ), + setMethod('DELETE'), + ); +}; + +/** + * Delete a sharegroup Image + * + * @param sharegroupId {string} ID of the sharegroup to delete + * @param imageId {string} ID of the image to delete + */ +export const deleteSharegroupImage = ( + sharegroupId: string, + imageId: string, +) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images/${encodeURIComponent(imageId)}`, + ), setMethod('DELETE'), ); }; @@ -235,3 +398,17 @@ export const deleteSharegroupMember = ( setMethod('DELETE'), ); }; + +/** + * Delete a user token + * + * @param token_uuid {string} Token UUID of the user to delete + */ +export const deleteSharegroupToken = (token_uuid: string) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + ), + setMethod('DELETE'), + ); +}; diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index f77301675e7..42aa1a4f9e1 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -226,6 +226,49 @@ export interface Sharegroup { uuid: string; } +export interface SharegroupImagePayload { + /** + * A detailed description of this Image. + */ + description?: string; + /** + * ID of the private image that will be added to the Sharegroup + */ + id: string; + /** + * A short title of this Image. + * + * Defaults to the label of the private image it is being created from if not provided. + */ + label?: string; +} + +export interface CreateSharegroupPayload { + /** + * A detailed description of this Sharegroup. + */ + description?: string; + /** + * An array of images that will be shared in the Sharegroup + */ + images?: SharegroupImagePayload[]; + /** + * A short title of this Sharegroup. + */ + label: string; +} + +export type UpdateSharegroupPayload = Omit; + +export interface AddSharegroupImagesPayload { + /** + * An array of images that will be shared in the Sharegroup + */ + images: SharegroupImagePayload[]; +} + +export type UpdateSharegroupImagePayload = Omit; + export interface AddSharegroupMemberPayload { /** * The title given to the user in the sharegroup diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx index f6cabb33ee7..e21d95274c3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx @@ -40,7 +40,8 @@ export const VPCIPv4Address = (props: Props) => { // Auto-assign should be checked if any of the following are true // - field value matches the identifier // - field value is undefined (because the API's default behavior is to auto-assign) - const shouldAutoAssign = fieldValue === autoAssignValue || fieldValue === undefined; + const shouldAutoAssign = + fieldValue === autoAssignValue || fieldValue === undefined; return ( diff --git a/packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md b/packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md new file mode 100644 index 00000000000..30c3ef1e3cb --- /dev/null +++ b/packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Add validation schemas for creating and updating Sharegroups and Sharegroup Images ([#12985](https://github.com/linode/manager/pull/12985)) diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index e5e84bae480..40d12f49610 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -40,11 +40,41 @@ export const updateImageRegionsSchema = object({ .min(1, 'Must specify at least one region.'), }); +export const sharegroupImageSchema = object({ + id: string().required('Image ID is required'), + label: labelSchema.optional(), + description: string().optional(), +}); + +export const addSharegroupImagesSchema = object({ + images: array(sharegroupImageSchema).required('Images are required.'), +}); + +export const updateSharegroupImageSchema = object({ + label: labelSchema.optional(), + description: string().optional(), +}); + +export const createSharegroupSchema = object({ + label: labelSchema.required('Label is required.'), + description: string().optional(), + images: array(sharegroupImageSchema).notRequired(), +}); + +export const updateSharegroupSchema = object({ + label: labelSchema.optional(), + description: string().optional(), +}); + export const addSharegroupMemberSchema = object({ token: string().required('Token is required.'), label: labelSchema.required('Label is required.'), }); +export const updateSharegroupMemberSchema = object({ + label: labelSchema.required('Label is required.'), +}); + export const generateSharegroupTokenSchema = object({ label: labelSchema.optional(), valid_for_sharegroup_uuid: boolean().required( @@ -55,7 +85,3 @@ export const generateSharegroupTokenSchema = object({ export const updateSharegroupTokenSchema = object({ label: labelSchema.required('Label is required.'), }); - -export const updateSharegroupMemberSchema = object({ - label: labelSchema.required('Label is required.'), -}); From e561700671a8108fd5915595aa6a307fd436e689 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:58:43 +0100 Subject: [PATCH 19/39] fix: [UIE-9386, UIE-9349, UIE-9387] - IAM parent/child data table sorting/filtering fixes (#13003) * iam/delegation fixes * iam/users/username/delegation fixes * missing query param * fix test * Added changeset: IAM Account Delegation Tables sorting & filtering --- .../pr-13003-fixed-1761144241306.md | 5 +++ .../IAM/Delegations/AccountDelegations.tsx | 42 ++++++++++++------- .../Delegations/AccountDelegationsTable.tsx | 4 +- .../UserDelegations/UserDelegations.test.tsx | 5 +++ .../Users/UserDelegations/UserDelegations.tsx | 23 ++++++---- 5 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 packages/manager/.changeset/pr-13003-fixed-1761144241306.md diff --git a/packages/manager/.changeset/pr-13003-fixed-1761144241306.md b/packages/manager/.changeset/pr-13003-fixed-1761144241306.md new file mode 100644 index 00000000000..564c306a983 --- /dev/null +++ b/packages/manager/.changeset/pr-13003-fixed-1761144241306.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM Account Delegation Tables sorting & filtering ([#13003](https://github.com/linode/manager/pull/13003)) diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx index f62ee88c6ea..b321df9cd72 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx @@ -8,6 +8,8 @@ import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextFiel import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { useFlags } from 'src/hooks/useFlags'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { AccountDelegationsTable } from './AccountDelegationsTable'; @@ -37,17 +39,22 @@ export const AccountDelegations = () => { users: true, }); - const [order, setOrder] = React.useState<'asc' | 'desc'>('asc'); - const [orderBy, setOrderBy] = React.useState('company'); + const pagination = usePaginationV2({ + currentRoute: '/iam/delegations', + initialPage: 1, + preferenceKey: 'iam-delegations-pagination', + }); - const handleOrderChange = (newOrderBy: string) => { - if (orderBy === newOrderBy) { - setOrder(order === 'asc' ? 'desc' : 'asc'); - } else { - setOrderBy(newOrderBy); - setOrder('asc'); - } - }; + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'company', + }, + from: '/iam/delegations', + }, + preferenceKey: 'iam-delegations-order', + }); // Apply search filter const filteredDelegations = React.useMemo(() => { @@ -78,6 +85,7 @@ export const AccountDelegations = () => { }, [filteredDelegations, order]); const handleSearch = (value: string) => { + pagination.handlePageChange(1); navigate({ to: DELEGATIONS_ROUTE, search: { query: value || undefined }, @@ -114,14 +122,18 @@ export const AccountDelegations = () => { /> - + {({ count, data: paginatedData, handlePageChange, handlePageSizeChange, - page, - pageSize, }) => ( <> { count={count} handlePageChange={handlePageChange} handleSizeChange={handlePageSizeChange} - page={page} - pageSize={pageSize} + page={pagination.page} + pageSize={pagination.pageSize} /> )} diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx index 14ff01ce355..1c464fa19ed 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx @@ -22,7 +22,7 @@ import type { interface Props { delegations: ChildAccount[] | ChildAccountWithDelegates[] | undefined; error: APIError[] | null; - handleOrderChange: (orderBy: string) => void; + handleOrderChange: (key: string, order?: 'asc' | 'desc') => void; isLoading: boolean; numCols: number; order: 'asc' | 'desc'; @@ -49,7 +49,7 @@ export const AccountDelegationsTable = ({ handleOrderChange('company')} + handleClick={handleOrderChange} label="company" style={{ width: '27%' }} > diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx index 167a7cf4c52..83e805ea987 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx @@ -21,6 +21,7 @@ const mockChildAccounts = [ const queryMocks = vi.hoisted(() => ({ useAllGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}), useParams: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { @@ -37,6 +38,7 @@ vi.mock('@tanstack/react-router', async () => { return { ...actual, useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, }; }); @@ -49,6 +51,9 @@ describe('UserDelegations', () => { data: mockChildAccounts, isLoading: false, }); + queryMocks.useSearch.mockReturnValue({ + query: '', + }); }); it('renders the correct number of child accounts', () => { diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx index 36502670ecf..557f1904adc 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx @@ -6,7 +6,7 @@ import { Stack, Typography, } from '@linode/ui'; -import { useParams } from '@tanstack/react-router'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import * as React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -28,7 +28,10 @@ import type { Theme } from '@mui/material'; export const UserDelegations = () => { const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const [search, setSearch] = React.useState(''); + const { query } = useSearch({ + from: '/iam/users/$username/delegations', + }); + const navigate = useNavigate(); // TODO: UIE-9298 - Replace with API filtering const { @@ -40,7 +43,12 @@ export const UserDelegations = () => { }); const handleSearch = (value: string) => { - setSearch(value); + pagination.handlePageChange(1); + navigate({ + to: '/iam/users/$username/delegations', + params: { username }, + search: { query: value || undefined }, + }); }; const childAccounts = React.useMemo(() => { @@ -48,14 +56,14 @@ export const UserDelegations = () => { return []; } - if (search.length === 0) { + if (query?.trim() === '') { return allDelegatedChildAccounts; } return allDelegatedChildAccounts.filter((childAccount) => - childAccount.company.toLowerCase().includes(search.toLowerCase()) + childAccount.company.toLowerCase().includes(query?.toLowerCase() ?? '') ); - }, [allDelegatedChildAccounts, search]); + }, [allDelegatedChildAccounts, query]); const { handleOrderChange, order, orderBy, sortedData } = useOrderV2({ data: childAccounts, @@ -92,6 +100,7 @@ export const UserDelegations = () => { Account Delegations { onSearch={handleSearch} placeholder="Search" sx={{ mt: 3 }} - value={search} + value={query ?? ''} /> From 75abf521ce2d6e4c373c7658ccc2ed8de46ad5e8 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Mon, 27 Oct 2025 16:48:33 +0100 Subject: [PATCH 20/39] upcoming: [DPS-35222] - Logs destination/stream delete modal error state does not reset (#12996) * upcoming: [DPS-35222] - Logs destination/stream delete modal error state does not reset * Added changeset: Logs Delivery Destinations/Stream Delete confirmation modal error state reset fix --- ...r-12996-upcoming-features-1760962341317.md | 5 ++ .../Destinations/DeleteDestinationDialog.tsx | 37 ++++++++++---- .../Destinations/DestinationsLanding.test.tsx | 50 +++++++++++++++++++ .../Delivery/Streams/DeleteStreamDialog.tsx | 36 +++++++++---- .../Delivery/Streams/StreamsLanding.test.tsx | 46 +++++++++++++++++ 5 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md diff --git a/packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md b/packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md new file mode 100644 index 00000000000..ec8379969f2 --- /dev/null +++ b/packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Logs Delivery Destinations/Stream Delete confirmation modal error state reset fix ([#12996](https://github.com/linode/manager/pull/12996)) diff --git a/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx b/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx index 020f86a47e8..ee2a8391531 100644 --- a/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx @@ -2,8 +2,10 @@ import { useDeleteDestinationMutation } from '@linode/queries'; import { ActionsPanel } from '@linode/ui'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; +import { useEffect } from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { Destination } from '@linode/api-v4'; @@ -15,24 +17,37 @@ interface Props { export const DeleteDestinationDialog = React.memo((props: Props) => { const { onClose, open, destination } = props; - const { - mutateAsync: deleteDestination, - isPending, - error, - } = useDeleteDestinationMutation(); + const { mutateAsync: deleteDestination, isPending } = + useDeleteDestinationMutation(); + const [deleteError, setDeleteError] = React.useState(); const handleDelete = () => { const { id, label } = destination as Destination; deleteDestination({ id, - }).then(() => { - onClose(); - return enqueueSnackbar(`Destination ${label} deleted successfully`, { - variant: 'success', + }) + .then(() => { + onClose(); + return enqueueSnackbar(`Destination ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + setDeleteError( + getAPIErrorOrDefault( + error, + 'There was an issue deleting your destination' + )[0].reason + ); }); - }); }; + useEffect(() => { + if (open) { + setDeleteError(undefined); + } + }, [open]); + const actions = ( { return ( { await checkClosedModal(deleteDestinationModal); }); + + it('should show error when cannot delete destination', async () => { + const mockDeleteDestinationMutation = vi.fn().mockRejectedValue([ + { + reason: + 'Destination with id 1 is attached to a stream and cannot be deleted', + }, + ]); + queryMocks.useDeleteDestinationMutation.mockReturnValue({ + mutateAsync: mockDeleteDestinationMutation, + }); + + renderComponent(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + const deleteDestinationModal = screen.getByText('Delete Destination'); + expect(deleteDestinationModal).toBeInTheDocument(); + + let errorIcon = screen.queryByTestId('ErrorOutlineIcon'); + expect(errorIcon).not.toBeInTheDocument(); + + // get delete Destination button + const deleteDestinationButton = screen.getByRole('button', { + name: 'Delete', + }); + await userEvent.click(deleteDestinationButton); + + expect(mockDeleteDestinationMutation).toHaveBeenCalledWith({ + id: 1, + }); + + // check for error state in modal + screen.getByTestId('ErrorOutlineIcon'); + + // close modal with Cancel button + const cancelModalDialogButton = screen.getByRole('button', { + name: 'Cancel', + }); + await userEvent.click(cancelModalDialogButton); + await checkClosedModal(deleteDestinationModal); + + // open delete confirmation modal again + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + // check for error state to be reset + errorIcon = screen.queryByTestId('ErrorOutlineIcon'); + expect(errorIcon).not.toBeInTheDocument(); + }); }); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx b/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx index b2344bb573b..0bf28a7f0e6 100644 --- a/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx +++ b/packages/manager/src/features/Delivery/Streams/DeleteStreamDialog.tsx @@ -2,8 +2,10 @@ import { useDeleteStreamMutation } from '@linode/queries'; import { ActionsPanel } from '@linode/ui'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; +import { useEffect } from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { Stream } from '@linode/api-v4'; @@ -15,24 +17,36 @@ interface Props { export const DeleteStreamDialog = React.memo((props: Props) => { const { onClose, open, stream } = props; - const { - mutateAsync: deleteStream, - isPending, - error, - } = useDeleteStreamMutation(); + const { mutateAsync: deleteStream, isPending } = useDeleteStreamMutation(); + const [deleteError, setDeleteError] = React.useState(); const handleDelete = () => { const { id, label } = stream as Stream; deleteStream({ id, - }).then(() => { - onClose(); - return enqueueSnackbar(`Stream ${label} deleted successfully`, { - variant: 'success', + }) + .then(() => { + onClose(); + return enqueueSnackbar(`Stream ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + setDeleteError( + getAPIErrorOrDefault( + error, + 'There was an issue deleting your stream' + )[0].reason + ); }); - }); }; + useEffect(() => { + if (open) { + setDeleteError(undefined); + } + }, [open]); + const actions = ( { return ( { await checkClosedModal(deleteStreamModal); }); + + it('should show error when cannot delete stream', async () => { + const mockDeleteStreamMutation = vi + .fn() + .mockRejectedValue([{ reason: 'Unexpected error' }]); + queryMocks.useDeleteStreamMutation.mockReturnValue({ + mutateAsync: mockDeleteStreamMutation, + }); + + renderComponent(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + const deleteStreamModal = screen.getByText('Delete Stream'); + expect(deleteStreamModal).toBeInTheDocument(); + + const errorIcon = screen.queryByTestId('ErrorOutlineIcon'); + expect(errorIcon).not.toBeInTheDocument(); + + // get delete Stream button + const deleteStreamButton = screen.getByRole('button', { + name: 'Delete', + }); + await userEvent.click(deleteStreamButton); + + expect(mockDeleteStreamMutation).toHaveBeenCalledWith({ + id: 1, + }); + + // check for error state in modal + screen.getByTestId('ErrorOutlineIcon'); + + // get modal Cancel button + const cancelModalDialogButton = screen.getByRole('button', { + name: 'Cancel', + }); + await userEvent.click(cancelModalDialogButton); + await checkClosedModal(deleteStreamModal); + + // open delete confirmation modal again + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + // check for error state to be reset + expect(errorIcon).not.toBeInTheDocument(); + }); }); }); }); From 10ee73310ddff38654983ce0545e96e1764d2ba1 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:03:55 -0400 Subject: [PATCH 21/39] test: [M3-9175] - Add tests for Linode Interfaces table in Linode Networking tab (part 2) (#12876) * add test for deleting interface and editing vlan interface * add settings test * giant tests * Added changeset: Add Linode Interface related tests: deleting an interface, editing interfaces, and updating interface settings * fix copies after merge * remove .only * fix tests --- .../pr-12876-tests-1757711939971.md | 5 + .../e2e/core/linodes/linode-network.spec.ts | 594 +++++++++++++++++- .../cypress/support/intercepts/linodes.ts | 78 +++ .../InterfaceSettingsForm.tsx | 2 +- 4 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-12876-tests-1757711939971.md diff --git a/packages/manager/.changeset/pr-12876-tests-1757711939971.md b/packages/manager/.changeset/pr-12876-tests-1757711939971.md new file mode 100644 index 00000000000..0a1a6be5069 --- /dev/null +++ b/packages/manager/.changeset/pr-12876-tests-1757711939971.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Interface related tests: deleting an interface, editing interfaces, and updating interface settings ([#12876](https://github.com/linode/manager/pull/12876)) diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts index 87840e9ae1a..d689f2f064e 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -2,6 +2,7 @@ import { linodeInterfaceFactoryPublic, linodeInterfaceFactoryVlan, linodeInterfaceFactoryVPC, + linodeInterfaceSettingsFactory, } from '@linode/utilities'; import { linodeFactory } from '@linode/utilities'; import { @@ -21,17 +22,25 @@ import { } from 'support/intercepts/firewalls'; import { mockCreateLinodeInterface, + mockDeleteLinodeInterface, mockGetLinodeDetails, mockGetLinodeFirewalls, mockGetLinodeInterface, mockGetLinodeInterfaces, + mockGetLinodeInterfaceSettings, mockGetLinodeIPAddresses, + mockUpdateLinodeInterface, + mockUpdateLinodeInterfaceSettings, } from 'support/intercepts/linodes'; import { mockUpdateIPAddress } from 'support/intercepts/networking'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; -import type { IPRange, LinodeIPsResponse } from '@linode/api-v4'; +import type { + FirewallDevice, + IPRange, + LinodeIPsResponse, +} from '@linode/api-v4'; describe('IP Addresses', () => { // TODO M3-9775: Set mock linode interface type to legacy once Linode Interfaces is GA. @@ -465,6 +474,169 @@ describe('Linode Interfaces enabled', () => { }); }); + it('confirms deletion of a an interface works as expected', () => { + const mockLinodeInterface = linodeInterfaceFactoryPublic.build(); + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [mockLinodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + mockLinodeInterface.id, + mockLinodeInterface + ); + mockDeleteLinodeInterface(mockLinode.id, mockLinodeInterface.id); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + + cy.findByText('No Network Interfaces exist on this Linode.').should( + 'not.exist' + ); + + cy.findByText(mockLinodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Public Interface (${mockLinodeInterface.id})` + ) + .should('be.visible') + .should('be.enabled') + .click(); + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [], + }).as('getInterfaces'); + + ui.dialog + .findByTitle(`Delete Public Interface (ID: ${mockLinodeInterface.id})?`) + .should('be.visible') + .within(() => { + cy.findByText( + 'Are you sure you want to delete this Public interface?' + ); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText('No Network Interfaces exist on this Linode.').should( + 'be.visible' + ); + }); + + it('confirms the Interface Settings form', () => { + const vpcInterface = linodeInterfaceFactoryVPC.build({ id: 1 }); + const publicInterface = linodeInterfaceFactoryPublic.build({ id: 2 }); + const interfaceSettings = linodeInterfaceSettingsFactory.build({ + default_route: { + ipv4_interface_id: publicInterface.id, + ipv6_interface_id: publicInterface.id, + }, + }); + const updatedInterfaceSettings = linodeInterfaceSettingsFactory.build({ + default_route: { + ipv4_interface_id: vpcInterface.id, + ipv6_interface_id: publicInterface.id, + }, + }); + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [vpcInterface, publicInterface], + }).as('getInterfaces'); + mockGetLinodeInterfaceSettings(mockLinode.id, interfaceSettings); + mockUpdateLinodeInterfaceSettings( + mockLinode.id, + updatedInterfaceSettings + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + + ui.button + .findByTitle('Interface Settings') + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the Interface Setting Drawer's contents + ui.drawer + .findByTitle('Interface Settings') + .should('be.visible') + .within(() => { + cy.findByText('Default Route Selection').should('be.visible'); + + // Confirm drawer reflects current Default Route values + ui.autocomplete + .findByLabel('Default IPv4 Route') + .should('be.visible') + .should('have.value', 'Public Interface (ID: 2)'); + + ui.autocomplete + .findByLabel('Default IPv6 Route') + .should('be.visible') + .should('have.value', 'Public Interface (ID: 2)'); + + cy.findByText('Enable Network Helper') + .should('be.visible') + .should('be.enabled'); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('not.be.enabled'); + + // Update Default IPv4 Route + ui.autocomplete + .findByLabel('Default IPv4 Route') + .type('VPC Interface (ID: 1)'); + + ui.autocompletePopper + .findByTitle('VPC Interface (ID: 1)', { exact: false }) + .should('be.visible') + .click(); + + // Confirm save button becomes enabled once changes are made + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // confirm toast upon success + ui.toast.assertMessage('Successfully updated interface settings.'); + + // re-open drawer + ui.button + .findByTitle('Interface Settings') + .should('be.visible') + .should('be.enabled') + .click(); + + // confirm settings have updated + ui.drawer + .findByTitle('Interface Settings') + .should('be.visible') + .within(() => { + ui.autocomplete + .findByLabel('Default IPv4 Route') + .should('be.visible') + .should('have.value', 'VPC Interface (ID: 1)'); + + ui.autocomplete + .findByLabel('Default IPv6 Route') + .should('be.visible') + .should('have.value', 'Public Interface (ID: 2)'); + }); + }); + describe('Adding a Linode Interface', () => { it('allows the user to add a VLAN interface', () => { const mockLinodeInterface = linodeInterfaceFactoryVlan.build(); @@ -936,5 +1108,425 @@ describe('Linode Interfaces enabled', () => { }); }); }); + + describe('Editing a Linode Interface', () => { + it('confirms VLAN interfaces cannot be edited', () => { + const linodeInterface = linodeInterfaceFactoryVlan.build(); + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [linodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + linodeInterface.id, + linodeInterface + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + + // Confirm edit drawer is disabled + cy.findByText(linodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for VLAN Interface (${linodeInterface.id})` + ) + .should('be.visible') + .should('be.enabled') + .click(); + + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('not.be.enabled'); + + ui.tooltip + .findByText('VLAN interfaces cannot be edited.') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /** + * - Confirms adding an IPv4 address and marking it as primary + * - Confirms adding an IPv6 /56 range + * - Confirms adding an IPv6 /64 range + * - Confirms updating a firewall + */ + it('confirms editing a public interface', () => { + const linodeInterface = linodeInterfaceFactoryPublic.build(); + const updatedLinodeInterface = linodeInterfaceFactoryPublic.build({ + id: linodeInterface.id, + public: { + ipv4: { + addresses: [ + { + address: '10.0.0.1', + primary: true, + }, + ], + shared: [], + }, + ipv6: { + ranges: [ + { + range: '2600:3c06:e001:149::/64', + route_target: null, + }, + { + range: '2600:3c06:e001:149::/56', + route_target: null, + }, + ], + shared: [], + slaac: [], + }, + }, + }); + const selectedFirewall = mockFirewalls[1]; + const mockFirewallDevice = { + id: linodeInterface.id, + entity: { + id: linodeInterface.id, + label: mockLinode.label, + parent_entity: null, + type: 'linode_interface', + url: '', + }, + created: '', + updated: '', + }; + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [linodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + linodeInterface.id, + linodeInterface + ); + mockGetFirewalls(mockFirewalls); + mockUpdateLinodeInterface( + mockLinode.id, + linodeInterface.id, + updatedLinodeInterface + ); + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, []); + mockAddFirewallDevice( + selectedFirewall.id, + mockFirewallDevice as FirewallDevice + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + cy.findByText(linodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Public Interface (${linodeInterface.id})` + ) + .should('be.visible') + .should('be.enabled') + .click(); + + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + // IPv4 Section + cy.findByText('IPv4 Addresses'); + + // Confirm primary chip exists for first public IPv4 address + cy.findByText('10.0.0.0') + .closest('tr') + .within(() => { + cy.findByText('Primary').should('be.visible'); + }); + + // Allocate IPv4 address, then reset form and confirm no changes saved + ui.button + .findByTitle('Add IPv4 Address') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('IP allocated on save').should('be.visible'); + ui.button + .findByTitle('Reset') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('IP allocated on save').should('not.exist'); + + // Allocate public IPv4 address and make it primary + ui.button + .findByTitle('Add IPv4 Address') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.button + .findByTitle('Make Primary') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm primary chip no longer is visible for first public IPv4 address + // Remove first IPv4 address + cy.findByText('10.0.0.0') + .closest('tr') + .within(() => { + cy.findByText('Primary').should('not.exist'); + ui.button + .findByTitle('Remove') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText('10.0.0.0').should('not.exist'); + + cy.findByText('IP allocated on save') + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Primary').should('be.visible'); + }); + + // IPv6 Section + cy.findByText('IPv6 Ranges').should('be.visible'); + cy.findByText( + 'No IPv6 ranges are assigned to this interface.' + ).should('be.visible'); + + // Add IPv6 /64 range and confirm result + ui.button + .findByTitle('Add IPv6 /64 Range') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('/64 range allocated on save').should('be.visible'); + + // Add /56 range and confirm result + ui.button + .findByTitle('Add IPv6 /56 Range') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('/56 range allocated on save').should('be.visible'); + + // Select a Firewall + ui.autocomplete.findByLabel('Firewall').click(); + ui.autocompletePopper.findByTitle(selectedFirewall.label).click(); + + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, [ + selectedFirewall, + ]); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm toast + ui.toast.assertMessage('Interface successfully updated.'); + + // Reopen Edit drawer and confirm changes + cy.findByText(linodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Public Interface (${linodeInterface.id})` + ) + .click(); + + ui.actionMenuItem.findByTitle('Edit').click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + // Confirm IPs + cy.findByText('10.0.0.1').should('be.visible'); + cy.findByText('2600:3c06:e001:149::/64').should('be.visible'); + cy.findByText('2600:3c06:e001:149::/56').should('be.visible'); + ui.autocomplete + .findByLabel('Firewall') + .should('have.value', selectedFirewall.label); + }); + }); + + /** + * - Confirms auto-assigning an IPv4 address + * - Confirms IPv4 ranges can be added + * - Confirms updating a firewall + */ + it('confirms editing a VPC interface', () => { + const linodeInterface = linodeInterfaceFactoryVPC.build({ + vpc: { + ipv4: { + addresses: [ + { + address: '10.0.0.0', + primary: true, + }, + ], + ranges: [], + }, + }, + }); + const updatedLinodeInterface = linodeInterfaceFactoryVPC.build({ + id: linodeInterface.id, + vpc: { + ipv4: { + addresses: [ + { + address: '10.0.0.0', + primary: true, + }, + ], + ranges: [{ range: '10.0.0.1' }], + }, + }, + }); + const selectedFirewall = mockFirewalls[1]; + const mockFirewallDevice = { + id: linodeInterface.id, + entity: { + id: linodeInterface.id, + label: mockLinode.label, + parent_entity: null, + type: 'linode_interface', + url: '', + }, + created: '', + updated: '', + }; + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [linodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + linodeInterface.id, + linodeInterface + ); + mockGetFirewalls(mockFirewalls); + mockUpdateLinodeInterface( + mockLinode.id, + linodeInterface.id, + updatedLinodeInterface + ); + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, []); + mockAddFirewallDevice( + selectedFirewall.id, + mockFirewallDevice as FirewallDevice + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + cy.findByText(linodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for VPC Interface (${linodeInterface.id})` + ) + .should('be.visible') + .should('be.enabled') + .click(); + + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + // confirm VPC IPv4 address and public IPv4 address checkboxes exist + cy.findByLabelText('VPC IPv4 (required)').should('be.visible'); + cy.findByText('Auto-assign VPC IPv4') + .should('be.visible') + .should('be.enabled'); + cy.findByText('Allow public IPv4 access (1:1 NAT)').should( + 'be.visible' + ); + + // Add an IPv4 range + ui.button + .findByTitle('Add IPv4 Range') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByLabelText('VPC IPv4 Range 0').type('10.0.0.1'); + // Select a Firewall + ui.autocomplete.findByLabel('Firewall').click(); + ui.autocompletePopper.findByTitle(selectedFirewall.label).click(); + + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, [ + selectedFirewall, + ]); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm toast + ui.toast.assertMessage('Interface successfully updated.'); + + // Reopen Edit drawer and confirm changes + cy.findByText(linodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for VPC Interface (${linodeInterface.id})` + ) + .click(); + + ui.actionMenuItem.findByTitle('Edit').click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + cy.findByLabelText('VPC IPv4 Range 0').should( + 'have.value', + '10.0.0.1' + ); + cy.findByLabelText('VPC IPv4 (required)').should( + 'have.value', + '10.0.0.0' + ); + ui.autocomplete + .findByLabel('Firewall') + .should('have.value', selectedFirewall.label); + }); + }); + }); }); }); diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 26c50864836..c5f0bd405bf 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -15,6 +15,7 @@ import type { Linode, LinodeInterface, LinodeInterfaces, + LinodeInterfaceSettings, LinodeIPsResponse, LinodeType, Stats, @@ -684,6 +685,44 @@ export const mockGetLinodeInterfaces = ( ); }; +/** + * Mocks GET request to get a Linode's Interface Settings. + * + * @param linodeId - ID of Linode to get interfaces associated with it + * @param settings - the mocked Linode Settings + * + * @returns Cypress Chainable. + */ +export const mockGetLinodeInterfaceSettings = ( + linodeId: number, + settings: LinodeInterfaceSettings +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/interfaces/settings`), + settings + ); +}; + +/** + * Intercepts PUT request to edit Linode's interface settings + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - the mocked Linode Settings + * + * @returns Cypress chainable. + */ +export const mockUpdateLinodeInterfaceSettings = ( + linodeId: number, + settings: LinodeInterfaceSettings +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}/interfaces/settings`), + settings + ); +}; + /** * Mocks GET request to get a single Linode Interface. * @@ -705,6 +744,45 @@ export const mockGetLinodeInterface = ( ); }; +/** + * Intercepts PUT request to edit Linode's interface settings + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - the mocked Linode Settings + * + * @returns Cypress chainable. + */ +export const mockUpdateLinodeInterface = ( + linodeId: number, + interfaceId: number, + linodeInterface: LinodeInterface +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}/interfaces/${interfaceId}`), + linodeInterface + ); +}; + +/** + * Intercepts DELETE request to delete linode interface and mocks response. + * + * @param linodeId - ID of Linode for intercepted request. + * @param interfaceId - ID of interface for intercepted request. + * + * @returns Cypress chainable. + */ +export const mockDeleteLinodeInterface = ( + linodeId: number, + interfaceId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`linode/instances/${linodeId}/interfaces/${interfaceId}`), + makeResponse({}) + ); +}; + /** * Intercepts POST request to create a Linode Interface. * diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx index ce8bdd55ef0..018159069f8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx @@ -54,7 +54,7 @@ export const InterfaceSettingsForm = (props: Props) => { const interfaces = interfacesData?.interfaces.map((networkInterface) => ({ ...networkInterface, - label: `${getLinodeInterfaceType(networkInterface)} Interface (ID : ${networkInterface.id})`, + label: `${getLinodeInterfaceType(networkInterface)} Interface (ID: ${networkInterface.id})`, })); const { mutateAsync: updateSettings } = From a8c4a8b00aab23474cd0a07dc15b9bc0282a53a4 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Tue, 28 Oct 2025 14:22:35 +0530 Subject: [PATCH 22/39] upcoming: [DI-27809] - Types and utility setup for adding widget level dimension filters (#13006) * DI-27358: Scaffolding and Utils set up for the widget dimension filters * DI-27358: Method and code refactoring * DI-27358: Some code refactorings * DI-27358: Fix lintings * DI-27358: Drive exclusions through filter config * DI-27358: add interface props * DI-27358: Add changeset * DI-27358: Some code refactorings * DI-27358: Address PR comments * DI-27358: Address PR comments --- ...r-13006-upcoming-features-1761296381753.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 3 +- ...r-13006-upcoming-features-1761296439986.md | 5 + packages/manager/src/assets/icons/filter.svg | 3 + .../manager/src/assets/icons/filterfilled.svg | 3 + packages/manager/src/featureFlags.ts | 5 + .../CloudPulse/Utils/FilterBuilder.ts | 22 ++ .../src/features/CloudPulse/Utils/models.ts | 5 + .../features/CloudPulse/Utils/utils.test.ts | 230 ++++++++++++++++++ .../src/features/CloudPulse/Utils/utils.ts | 133 ++++++++++ .../components/DimensionFilters/schema.ts | 36 +++ .../components/DimensionFilters/types.ts | 30 +++ packages/manager/src/mocks/serverHandlers.ts | 138 ++++++++++- .../utilities/src/__data__/regionsData.ts | 30 ++- 14 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md create mode 100644 packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md create mode 100644 packages/manager/src/assets/icons/filter.svg create mode 100644 packages/manager/src/assets/icons/filterfilled.svg create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/schema.ts create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/types.ts diff --git a/packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md b/packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md new file mode 100644 index 00000000000..8f931054913 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add new `filters` prop in AclpWidget type and update `Filters` type to use `DimensionFilterOperatorType` for operator ([#13006](https://github.com/linode/manager/pull/13006)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 20c9f32e8b2..787cc496c44 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -89,7 +89,7 @@ export interface Widgets { export interface Filters { dimension_label: string; - operator: string; + operator: DimensionFilterOperatorType; value: string; } @@ -111,6 +111,7 @@ export interface AclpConfig { export interface AclpWidget { aggregateFunction: string; + filters: Filters[]; groupBy?: string[]; label: string; size: number; diff --git a/packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md b/packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md new file mode 100644 index 00000000000..07e354eaf8b --- /dev/null +++ b/packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add type, utility and mock setup for supporting widget level dimension filters ([#13006](https://github.com/linode/manager/pull/13006)) diff --git a/packages/manager/src/assets/icons/filter.svg b/packages/manager/src/assets/icons/filter.svg new file mode 100644 index 00000000000..a27728327cf --- /dev/null +++ b/packages/manager/src/assets/icons/filter.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/filterfilled.svg b/packages/manager/src/assets/icons/filterfilled.svg new file mode 100644 index 00000000000..bdbd7f01881 --- /dev/null +++ b/packages/manager/src/assets/icons/filterfilled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 01b88c8d055..358108f3acc 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -95,6 +95,11 @@ interface AclpFlag { * This property indicates whether the feature is enabled */ enabled: boolean; + + /** + * This property indicates whether to show widget dimension filters or not + */ + showWidgetDimensionFilters?: boolean; } interface LkeEnterpriseFlag extends BaseFeatureFlag { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index f8d38a62740..5d70ae185f0 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -29,6 +29,7 @@ import type { import type { CloudPulseTextFilterProps } from '../shared/CloudPulseTextFilter'; import type { CloudPulseTimeRangeSelectProps } from '../shared/CloudPulseTimeRangeSelect'; import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget'; +import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; import type { CloudPulseServiceTypeFilters } from './models'; import type { AclpConfig, @@ -578,6 +579,27 @@ export const constructAdditionalRequestFilters = ( return filters; }; +/** + * @param dimensionFilters The selected dimension filters from the dimension filter component + * @returns The list of filters for the metric API call, based the additional custom select components + */ +export const constructWidgetDimensionFilters = ( + dimensionFilters: MetricsDimensionFilter[] +): Filters[] => { + const filters: Filters[] = []; + for (const { dimension_label, operator, value } of dimensionFilters) { + if (dimension_label && operator && value) { + // push to the filters + filters.push({ + dimension_label, + operator, + value, + }); + } + } + return filters; +}; + /** * * @param filterKey The filterKey of the actual filter diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index d7b830f8077..0908688e4de 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -103,6 +103,11 @@ export interface CloudPulseServiceTypeFiltersConfiguration { */ dependency?: string[]; + /** + * If this filter is part of metric-definitions API, this field holds the dimension key + */ + dimensionKey?: string; + /** * This is the field that will be sent in the metrics api call or xFilter */ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index 8215a83b3fa..c340d3df30e 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -22,11 +22,16 @@ import { areValidInterfaceIds, getAssociatedEntityType, getEnabledServiceTypes, + getFilteredDimensions, + isValidFilter, isValidPort, useIsAclpSupportedRegion, validationFunction, } from './utils'; +import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; +import type { Dimension } from '@linode/api-v4'; import type { AclpServices } from 'src/featureFlags'; describe('isValidPort', () => { @@ -354,3 +359,228 @@ describe('getEnabledServiceTypes', () => { }); }); }); + +describe('isValidFilter', () => { + const valuedDim: Dimension = { + dimension_label: 'browser', + label: 'Browser', + values: ['chrome', 'firefox', 'safari'], + }; + + const staticDim: Dimension = { + dimension_label: 'browser', + label: 'Browser', + values: [], + }; + + it('returns false when operator is missing', () => { + const filter = { + dimension_label: 'browser', + operator: null, + value: 'chrome', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(false); + }); + + it('returns false when the dimension_label is not present in options', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'os', + operator: 'eq', + value: 'linux', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(false); + }); + + it('returns true for static dimensions (no values array) regardless of value', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'eq', + value: 'chrome', + }; + expect(isValidFilter(filter, [staticDim])).toBe(true); + }); + + it('allows pattern operators ("endswith" / "startswith") even without validating values', () => { + const f1: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'endswith', + value: 'fox', + }; + const f2: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'startswith', + value: 'chr', + }; + expect(isValidFilter(f1, [valuedDim])).toBe(true); + expect(isValidFilter(f2, [valuedDim])).toBe(true); + }); + + it('returns true when multiple comma-separated values are all valid', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'in', + value: 'chrome,firefox', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(true); + }); + + it('returns false when value is empty string for a dimension that expects values', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'eq', + value: '', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(false); + }); +}); + +describe('getFilteredDimensions', () => { + it('returns [] when no dimensionFilters provided', () => { + const dimensions: Dimension[] = [ + { dimension_label: 'linode_id', values: [], label: 'Linode' }, + { dimension_label: 'vpc_subnet_id', values: [], label: 'Linode' }, + ]; + + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const result = getFilteredDimensions({ + dimensions, + linodes, + vpcs, + dimensionFilters: [], + }); + expect(result).toEqual([]); + }); + + it('merges linode and vpc values into metric dimensions and keeps valid filters', () => { + const dimensions: Dimension[] = [ + { dimension_label: 'linode_id', values: [], label: 'Linode' }, + { dimension_label: 'vpc_subnet_id', values: [], label: 'VPC subnet ID' }, + { + dimension_label: 'browser', + values: ['chrome', 'firefox'], + label: 'browser', + }, + ]; + + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const filters: MetricsDimensionFilter[] = [ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + { dimension_label: 'vpc_subnet_id', operator: 'eq', value: 'vpc-1' }, + { dimension_label: 'browser', operator: 'in', value: 'chrome' }, + ]; + + const result = getFilteredDimensions({ + dimensions, + linodes, + vpcs, + dimensionFilters: filters, + }); + + // all three filters are valid against mergedDimensions + expect(result).toHaveLength(3); + expect(result).toEqual(expect.arrayContaining(filters)); + }); + + it('filters out invalid filters (values not present in merged dimension values)', () => { + const dimensions: Dimension[] = [ + { dimension_label: 'linode_id', values: [], label: 'Linode' }, + { + dimension_label: 'vpc_subnet_id', + values: [], + label: 'VPC subnet Id', + }, + { + dimension_label: 'browser', + values: ['chrome', 'firefox'], + label: 'Browser', + }, + ]; + + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const filters: MetricsDimensionFilter[] = [ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + { dimension_label: 'vpc_subnet_id', operator: 'eq', value: 'vpc-1' }, + // invalid browser value -- should be removed + { dimension_label: 'browser', operator: 'in', value: 'edge' }, + ]; + + const result = getFilteredDimensions({ + dimensions, + linodes, + vpcs, + dimensionFilters: filters, + }); + + // only the two valid filters should remain + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + { dimension_label: 'vpc_subnet_id', operator: 'eq', value: 'vpc-1' }, + ]) + ); + // invalid 'browser' filter must be absent + expect(result).toEqual( + expect.not.arrayContaining([ + { dimension_label: 'browser', operator: 'in', value: 'edge' }, + ]) + ); + }); + + it('returns [] when dimensions is empty', () => { + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const filters: MetricsDimensionFilter[] = [ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + ]; + + const result = getFilteredDimensions({ + dimensions: [], + linodes, + vpcs, + dimensionFilters: filters, + }); + + // with no metric definitions, mergedDimensions is undefined and filters should not pass validation + expect(result).toEqual([]); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 7b6310212d9..1715705b993 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -5,6 +5,8 @@ import React from 'react'; import { convertData } from 'src/features/Longview/shared/formatters'; import { useFlags } from 'src/hooks/useFlags'; +import { valueFieldConfig } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import { getOperatorGroup } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/utils'; import { arraysEqual } from '../Alerts/Utils/utils'; import { INTERFACE_ID, @@ -23,6 +25,8 @@ import { } from './constants'; import { FILTER_CONFIG } from './FilterConfig'; +import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; import type { Alert, APIError, @@ -30,6 +34,7 @@ import type { CloudPulseAlertsPayload, CloudPulseServiceType, Dashboard, + Dimension, MonitoringCapabilities, ResourcePage, Service, @@ -58,6 +63,25 @@ interface AclpSupportedRegionProps { type: keyof MonitoringCapabilities; } +interface FilterProps { + /** + * The dimension filters to be validated + */ + dimensionFilters: MetricsDimensionFilter[] | undefined; + /** + * The dimension options associated with the metric + */ + dimensions: Dimension[]; + /** + * The fetch options for linodes + */ + linodes: FetchOptions; + /** + * The fetch options for vpcs + */ + vpcs: FetchOptions; +} + /** * * @returns an object that contains boolean property to check whether aclp is enabled or not @@ -396,6 +420,115 @@ export const useIsAclpSupportedRegion = ( return region?.monitors?.[type]?.includes(capability) ?? false; }; +/** + * Checks if the given value is a valid number according to the specified config. + * @param raw The value to validate + * @param config Optional configuration object with min and max properties + */ +const isValueAValidNumber = ( + value: string, + config: undefined | { max?: number; min?: number } +): boolean => { + const trimmed = value.trim(); + if (trimmed === '') return false; + // try to parse as finite number + const num = Number(trimmed); + if (!Number.isFinite(num)) return false; + + // If min/max are integers (or present) enforce range. + if (config?.min !== undefined && num < config.min) return false; + if (config?.max !== undefined && num > config.max) return false; + + // If min/max are integers and config min/max are integers, it likely expects integer inputs + // (e.g. ports, ids). We'll enforce integer if both min and max are integer values. + if ( + config && + Number.isInteger(config.min ?? 0) && + Number.isInteger(config.max ?? 0) + ) { + // If both min and max exist and are integers, require the input be integer. + // If only one exists and it's an integer, still reasonable to require integer. + if (!Number.isInteger(num)) return false; + } + + return true; +}; + +/** + * @param filter The filter associated with the metric + * @param options The dimension options associated with the metric + * @returns boolean + */ +export const isValidFilter = ( + filter: MetricsDimensionFilter, + options: Dimension[] +): boolean => { + if (!filter.operator || !filter.dimension_label || !filter.value) + return false; + + const operator = filter.operator; + const operatorGroup = getOperatorGroup(operator); + + if (!operatorGroup.includes(operator)) return false; + + const dimension = options.find( + ({ dimension_label: dimensionLabel }) => + dimensionLabel === filter.dimension_label + ); + if (!dimension) return false; + + const dimensionConfig = + valueFieldConfig[filter.dimension_label] ?? valueFieldConfig['*']; + + const dimensionFieldConfig = dimensionConfig[operatorGroup]; + + if ( + dimensionFieldConfig.type === 'textfield' && + dimensionFieldConfig.inputType === 'number' + ) { + return isValueAValidNumber( + String(filter.value ?? ''), + dimensionFieldConfig + ); + } else if ( + dimensionFieldConfig.type === 'textfield' || + !dimension.values || + !dimension.values.length + ) { + return true; + } + + const validValues = new Set(dimension.values); + return (filter.value ?? '') + .split(',') + .every((value) => validValues.has(value)); +}; + +/** + * @param linodes The list of linode according to the supported regions + * @param vpcs The list of vpcs according to the supported regions + * @param dimensionFilters The array of dimension filters selected + * @returns The filtered dimension filter based on the selections + */ +export const getFilteredDimensions = ( + filterProps: FilterProps +): MetricsDimensionFilter[] => { + const { dimensions, linodes, vpcs, dimensionFilters } = filterProps; + + const mergedDimensions = dimensions.map((dim) => + dim.dimension_label === 'linode_id' + ? { ...dim, values: linodes.values.map((lin) => lin.value) } + : dim.dimension_label === 'vpc_subnet_id' + ? { ...dim, values: vpcs.values.map((vpc) => vpc.value) } + : dim + ); + return dimensionFilters?.length + ? dimensionFilters.filter((filter) => + isValidFilter(filter, mergedDimensions ?? []) + ) + : []; +}; + /** * @param dashboardId The id of the dashboard * @returns The associated entity type for the dashboard diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/schema.ts b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/schema.ts new file mode 100644 index 00000000000..c45788b7d7e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/schema.ts @@ -0,0 +1,36 @@ +import { array, lazy, object, string } from 'yup'; + +import { getDimensionFilterValueSchema } from 'src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas'; + +const fieldErrorMessage = 'This field is required.'; + +/** + * Yup schema for validating a single dimension filter + */ +export const dimensionFiltersSchema = object({ + dimension_label: string() + .required(fieldErrorMessage) + .nullable() + .test('nonNull', fieldErrorMessage, (value) => value !== null), + operator: string() + .oneOf(['eq', 'neq', 'startswith', 'endswith', 'in']) + .required(fieldErrorMessage) + .nullable() + .test('nonNull', fieldErrorMessage, (value) => value !== null), + value: lazy((_, context) => { + const { dimension_label, operator } = context.parent; + return getDimensionFilterValueSchema({ + dimensionLabel: dimension_label, + operator, + }) + .defined() + .nullable(); + }), +}); + +/** + * Yup schema for validating the entire dimension filters form + */ +export const metricDimensionFiltersSchema = object({ + dimension_filters: array().of(dimensionFiltersSchema).required(), +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/types.ts b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/types.ts new file mode 100644 index 00000000000..7b67c3bf426 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/types.ts @@ -0,0 +1,30 @@ +export interface MetricsDimensionFilterForm { + /** + * A list of filters applied on different metric dimensions. + */ + dimension_filters: MetricsDimensionFilter[]; +} + +export interface MetricsDimensionFilter { + /** + * The label or name of the metric dimension to filter on. + */ + dimension_label: null | string; + + /** + * The comparison operator used for filtering. + */ + operator: MetricsDimensionFilterOperatorType | null; + + /** + * The value to compare against. + */ + value: null | string; +} + +export type MetricsDimensionFilterOperatorType = + | 'endswith' + | 'eq' + | 'in' + | 'neq' + | 'startswith'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 19ad6106f06..5c1f400e965 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3407,6 +3407,27 @@ export const handlers = [ scrape_interval: '30s', unit: 'ops_per_second', }, + { + label: 'Network Traffic', + metric: 'vm_network_bytes_total', + unit: 'Kbps', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'Traffic Pattern', + dimension_label: 'pattern', + values: ['publicin', 'publicout', 'privatein', 'privateout'], + }, + { + label: 'Protocol', + dimension_label: 'protocol', + values: ['ipv4', 'ipv6'], + }, + ], + }, ], }; @@ -3566,18 +3587,91 @@ export const handlers = [ http.get('*/monitor/dashboards/:id', ({ params }) => { let serviceType: string; let dashboardLabel: string; + let widgets; const id = params.id; if (id === '1') { serviceType = 'dbaas'; dashboardLabel = 'DBaaS Service I/O Statistics'; + widgets = [ + { + metric: 'cpu_usage', + unit: '%', + label: 'CPU Usage', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'cpu_usage', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + { + metric: 'memory_usage', + unit: '%', + label: 'Memory Usage', + color: 'default', + size: 6, + chart_type: 'area', + y_label: 'memory_usage', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + ]; } else if (id === '3') { serviceType = 'nodebalancer'; dashboardLabel = 'NodeBalancer Service I/O Statistics'; + widgets = [ + { + metric: 'nb_ingress_traffic_rate', + unit: 'Bps', + label: 'Ingress Traffic Rate', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'nb_ingress_traffic_rate', + group_by: ['entity_id'], + aggregate_function: 'sum', + }, + { + metric: 'nb_egress_traffic_rate', + unit: 'Bps', + label: 'Egress Traffic Rate', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'nb_egress_traffic_rate', + group_by: ['entity_id'], + aggregate_function: 'sum', + }, + ]; } else if (id === '4') { serviceType = 'firewall'; dashboardLabel = 'Firewall Service I/O Statistics'; + widgets = [ + { + metric: 'fw_active_connections', + unit: 'Count', + label: 'Current Connections', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'fw_active_connections', + group_by: ['entity_id', 'linode_id', 'interface_id'], + aggregate_function: 'avg', + }, + { + metric: 'fw_available_connections', + unit: 'Count', + label: 'Available Connections', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'fw_available_connections', + group_by: ['entity_id', 'linode_id', 'interface_id'], + aggregate_function: 'avg', + }, + ]; } else if (id === '6') { serviceType = 'objectstorage'; dashboardLabel = 'Object Storage Service I/O Statistics'; @@ -3590,6 +3684,48 @@ export const handlers = [ } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; + widgets = [ + { + metric: 'vm_cpu_time_total', + unit: '%', + label: 'CPU Usage by Instance', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'vm_cpu_time_total', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + { + metric: 'vm_local_disk_iops_total', + unit: 'IOPS', + label: 'Local Disk I/O by Instance', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'vm_local_disk_iops_total', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + { + metric: 'vm_network_bytes_total', + unit: 'Kbps', + label: 'Network Traffic In by Instance', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'vm_network_bytes_total', + group_by: ['entity_id'], + aggregate_function: 'avg', + filters: [ + { + dimension_label: 'pattern', + operator: 'in', + value: 'publicin', + }, + ], + }, + ]; } const response = { @@ -3599,7 +3735,7 @@ export const handlers = [ service_type: serviceType, type: 'standard', updated: null, - widgets: [ + widgets: widgets || [ { aggregate_function: 'avg', chart_type: 'area', diff --git a/packages/utilities/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts index a730cbfae83..a65e1bb0729 100644 --- a/packages/utilities/src/__data__/regionsData.ts +++ b/packages/utilities/src/__data__/regionsData.ts @@ -30,7 +30,13 @@ export const regions: Region[] = [ status: 'ok', monitors: { alerts: ['Cloud Firewall', 'Object Storage'], - metrics: ['Block Storage', 'Object Storage'], + metrics: [ + 'Object Storage', + 'Cloud Firewall', + 'Linodes', + 'Managed Databases', + 'Block Storage', + ], }, }, { @@ -117,7 +123,15 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: ['Linodes', 'Object Storage'], metrics: ['Linodes'] }, + monitors: { + alerts: ['Linodes', 'Object Storage'], + metrics: [ + 'Object Storage', + 'Cloud Firewall', + 'Linodes', + 'Managed Databases', + ], + }, }, { capabilities: [ @@ -177,7 +191,15 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: [], metrics: [] }, + monitors: { + alerts: ['Cloud Firewall'], + metrics: [ + 'Linodes', + 'Managed Databases', + 'Cloud Firewall', + 'NodeBalancers', + ], + }, }, { capabilities: [ @@ -610,7 +632,7 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: ['Linodes'], metrics: [] }, + monitors: { alerts: ['Linodes'], metrics: ['NodeBalancers'] }, }, { capabilities: [ From ca47bf5304c1562ed51c513858ea70ba7308adc0 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Tue, 28 Oct 2025 10:46:49 +0100 Subject: [PATCH 23/39] upcoming: [DPS-35205] Stream form bug fixes (#12999) * upcoming: [DPS-35205] Remove sort on clusters table Log Generation column in Create Stream form * Added changeset: Removed sort on Log Generation Kubernetes Clusters table in Stream form * Add additional changes with test connection button and destination verification payload * Update mock behavior for Destination object - access_key_secret is not returned in Destination object from API * Fix type errors in e2e after changes; Path sample undefined date error * changeset update * Hide Include all cluster checkbox for beta --- packages/api-v4/src/delivery/types.ts | 8 ++- ...r-12999-upcoming-features-1761042187222.md | 5 ++ .../core/delivery/create-destination.spec.ts | 4 +- .../core/delivery/edit-destination.spec.ts | 9 ++-- .../cypress/support/constants/delivery.ts | 13 ++--- .../support/ui/pages/logs-destination-form.ts | 8 +-- packages/manager/src/factories/delivery.ts | 1 - .../DestinationForm/DestinationEdit.test.tsx | 10 +++- .../DestinationForm/DestinationEdit.tsx | 5 +- .../Destinations/DestinationsLanding.tsx | 1 - .../DeliveryTabHeader/DeliveryTabHeader.tsx | 3 -- .../features/Delivery/Shared/PathSample.tsx | 2 +- .../src/features/Delivery/Shared/types.ts | 9 +++- .../Delivery/Shared/useVerifyDestination.ts | 11 ++-- .../Clusters/StreamFormClusters.test.tsx | 2 +- .../Clusters/StreamFormClusters.tsx | 50 ++++++++++--------- .../Clusters/StreamFormClustersTable.tsx | 12 +---- .../Delivery/StreamFormDelivery.tsx | 13 +++-- .../Streams/StreamForm/StreamCreate.test.tsx | 14 +----- .../Streams/StreamForm/StreamEdit.test.tsx | 14 +----- .../Delivery/Streams/StreamsLanding.tsx | 1 - .../features/Delivery/deliveryUtils.test.ts | 4 +- .../src/features/Delivery/deliveryUtils.ts | 4 +- .../mocks/presets/crud/handlers/delivery.ts | 7 ++- 24 files changed, 107 insertions(+), 103 deletions(-) create mode 100644 packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index c0810f6dde1..4d98fea32d7 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -60,12 +60,16 @@ export type DestinationDetails = export interface AkamaiObjectStorageDetails { access_key_id: string; - access_key_secret: string; bucket_name: string; host: string; path: string; } +export interface AkamaiObjectStorageDetailsExtended + extends AkamaiObjectStorageDetails { + access_key_secret: string; +} + type ContentType = 'application/json' | 'application/json; charset=utf-8'; type DataCompressionType = 'gzip' | 'None'; @@ -122,7 +126,7 @@ export interface UpdateStreamPayloadWithId extends UpdateStreamPayload { } export interface AkamaiObjectStorageDetailsPayload - extends Omit { + extends Omit { path?: string; } diff --git a/packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md b/packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md new file mode 100644 index 00000000000..2fb1a1c4bb2 --- /dev/null +++ b/packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Stream form bug fixes ([#12999](https://github.com/linode/manager/pull/12999)) diff --git a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts index 31c74fff140..e035a3445b4 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts @@ -11,7 +11,7 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; -import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; +import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; describe('Create Destination', () => { before(() => { @@ -27,7 +27,7 @@ describe('Create Destination', () => { logsDestinationForm.setLabel(mockDestinationPayload.label); logsDestinationForm.fillDestinationDetailsForm( - mockDestinationPayload.details as AkamaiObjectStorageDetails + mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended ); // Create Destination should be disabled before test connection 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 2f458580034..ffce8d67d0c 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts @@ -1,6 +1,7 @@ import { mockDestination, mockDestinationPayload, + mockDestinationPayloadWithId, } from 'support/constants/delivery'; import { mockGetDestination, @@ -15,7 +16,7 @@ import { randomLabel } from 'support/util/random'; import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; -import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; +import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; describe('Edit Destination', () => { beforeEach(() => { @@ -39,7 +40,7 @@ describe('Edit Destination', () => { it('edit destination with incorrect data', () => { logsDestinationForm.fillDestinationDetailsForm( - mockDestinationPayload.details as AkamaiObjectStorageDetails + mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended ); // Create Destination should be disabled before test connection @@ -66,7 +67,7 @@ describe('Edit Destination', () => { logsDestinationForm.setLabel(newLabel); logsDestinationForm.fillDestinationDetailsForm( - mockDestinationPayload.details as AkamaiObjectStorageDetails + mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended ); // Create Destination should be disabled before test connection @@ -84,7 +85,7 @@ describe('Edit Destination', () => { ); const updatedDestination = { ...mockDestination, label: newLabel }; - mockUpdateDestination(mockDestination, updatedDestination); + mockUpdateDestination(mockDestinationPayloadWithId, updatedDestination); mockGetDestinations([updatedDestination]); // Submit the destination edit form cy.findByRole('button', { name: 'Edit Destination' }) diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts index e2be89d8c08..1d3a03f5e36 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -5,20 +5,12 @@ import { destinationFactory } from 'src/factories'; import type { Destination } from '@linode/api-v4'; -export const regions = [ - { - id: 'pl-labkrk-2', - label: 'PL, Krakow (pl-labkrk-2)', - }, -]; - export const mockDestinationPayload = { label: randomLabel(), type: destinationType.AkamaiObjectStorage, details: { host: randomString(), bucket_name: randomString(), - region: 'pl-labkrk-2', access_key_id: randomString(), access_key_secret: randomString(), path: '/', @@ -30,3 +22,8 @@ export const mockDestination: Destination = destinationFactory.build({ ...mockDestinationPayload, version: '1.0', }); + +export const mockDestinationPayloadWithId = { + id: mockDestination.id, + ...mockDestinationPayload, +}; diff --git a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts index 8c1f4dc7c8b..1a15feffa7a 100644 --- a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts +++ b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts @@ -4,7 +4,7 @@ * Create/Edit Stream Page */ -import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; +import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; export const logsDestinationForm = { /** @@ -78,11 +78,11 @@ export const logsDestinationForm = { }, /** - * Fills all form fields related to destination's details (LinodeObjectStorageDetails type) + * Fills all form fields related to destination's details (AkamaiObjectStorageDetails type) * - * @param data - object with destination details of LinodeObjectStorageDetails type + * @param data - object with destination details of AkamaiObjectStorageDetails type */ - fillDestinationDetailsForm: (data: AkamaiObjectStorageDetails) => { + fillDestinationDetailsForm: (data: AkamaiObjectStorageDetailsExtended) => { // Give Destination a host logsDestinationForm.setHost(data.host); diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index 9c875d70441..5bc57f475c5 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -6,7 +6,6 @@ import type { Destination } from '@linode/api-v4'; export const destinationFactory = Factory.Sync.makeFactory({ details: { access_key_id: 'Access Id', - access_key_secret: 'Access Secret', bucket_name: 'Bucket Name', host: '3000', path: 'file', 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 da08e80ad6d..50f850619ae 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -54,7 +54,7 @@ describe('DestinationEdit', () => { assertInputHasValue('Host', '3000'); assertInputHasValue('Bucket', 'Bucket Name'); assertInputHasValue('Access Key ID', 'Access Id'); - assertInputHasValue('Secret Access Key', 'Access Secret'); + assertInputHasValue('Secret Access Key', ''); assertInputHasValue('Log Path Prefix', 'file'); }); @@ -93,6 +93,10 @@ describe('DestinationEdit', () => { name: editDestinationButtonText, }); + // Enter Secret Access Key + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + expect(editDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); @@ -131,6 +135,10 @@ describe('DestinationEdit', () => { name: editDestinationButtonText, }); + // Enter Secret Access Key + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + expect(editDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index 6e8ef48e0b2..32af3c9908a 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -14,6 +14,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; +import { getDestinationPayloadDetails } from 'src/features/Delivery/deliveryUtils'; import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -77,9 +78,11 @@ export const DestinationEdit = () => { }, [destination, form]); const onSubmit = () => { + const formValues = form.getValues(); const destination: UpdateDestinationPayloadWithId = { id: destinationId, - ...omitProps(form.getValues(), ['type']), + ...omitProps(formValues, ['type']), + details: getDestinationPayloadDetails(formValues.details), }; updateDestination(destination) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx index 81e7750f376..6012361be59 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx @@ -123,7 +123,6 @@ export const DestinationsLanding = () => { void; onSearch?: (label: string) => void; onSelect?: (status: string) => void; @@ -30,7 +29,6 @@ export const DeliveryTabHeader = ({ createButtonText, disabledCreateButton, entity, - loading, onButtonClick, spacingBottom = 24, isSearching, @@ -115,7 +113,6 @@ export const DeliveryTabHeader = ({ diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx index 41bd9b69874..b94b717a772 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx @@ -1,5 +1,7 @@ import { useAccountRoles, + useGetDefaultDelegationAccessQuery, + useUpdateDefaultDelegationAccessQuery, useUserRoles, useUserRolesMutation, } from '@linode/queries'; @@ -17,6 +19,7 @@ import { Controller, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { AssignedPermissionsPanel } from '../AssignedPermissionsPanel/AssignedPermissionsPanel'; import { ROLES_LEARN_MORE_LINK } from '../constants'; import { @@ -40,15 +43,32 @@ interface Props { export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { const theme = useTheme(); - const { username } = useParams({ from: '/iam/users/$username' }); - + const { username } = useParams({ strict: false }); const { data: accountRoles, isLoading: accountPermissionsLoading } = useAccountRoles(); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { data: defaultRolesData } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + const { data: userRolesData } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? defaultRolesData + : userRolesData; const { mutateAsync: updateUserRoles } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultRoles } = + useUpdateDefaultDelegationAccessQuery(); + + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultRoles + : updateUserRoles; const formattedAssignedEntities: EntitiesOption[] = React.useMemo(() => { if (!role || !role.entity_names || !role.entity_ids) { return []; @@ -132,7 +152,7 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { newRole, }); - await updateUserRoles(updatedUserRoles); + await mutationFn(updatedUserRoles); handleClose(); } catch (errors) { diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx index 5632c5bcdf5..a6dcfefdccd 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx @@ -28,7 +28,7 @@ const props = { }; const queryMocks = vi.hoisted(() => ({ - useParams: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({ username: 'test_user' }), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), })); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx index 12d37c6f68a..6a568d48506 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx @@ -1,4 +1,9 @@ -import { useUserRoles, useUserRolesMutation } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUpdateDefaultDelegationAccessQuery, + useUserRoles, + useUserRolesMutation, +} from '@linode/queries'; import { ActionsPanel, Notice, Typography } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; @@ -6,6 +11,7 @@ import React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { deleteUserRole, getErrorMessage } from '../utilities'; import type { ExtendedRoleView } from '../types'; @@ -19,10 +25,22 @@ interface Props { export const UnassignRoleConfirmationDialog = (props: Props) => { const { onClose: _onClose, onSuccess, open, role } = props; - const { username } = useParams({ from: '/iam/users/$username' }); - const { enqueueSnackbar } = useSnackbar(); + const { username } = useParams({ strict: false }); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { data: defaultRolesData } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const { data: userRolesData } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? defaultRolesData + : userRolesData; const { error, isPending, @@ -30,7 +48,12 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { reset, } = useUserRolesMutation(username); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { mutateAsync: updateDefaultRoles, isPending: isDefaultRolesPending } = + useUpdateDefaultDelegationAccessQuery(); + + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultRoles + : updateUserRoles; const onClose = () => { reset(); // resets the error state of the useMutation @@ -47,7 +70,7 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { initialRole, }); - await updateUserRoles(updatedUserRoles); + await mutationFn(updatedUserRoles); enqueueSnackbar(`Role ${role?.name} has been deleted successfully.`, { variant: 'success', @@ -64,7 +87,7 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { { const theme = useTheme(); + const { username } = useParams({ strict: false }); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { data: defaultRolesData } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); - const { username } = useParams({ from: '/iam/users/$username' }); - - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: userRolesData } = useUserRoles( + username, + !isDefaultDelegationRolesForChildAccount + ); + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? defaultRolesData + : userRolesData; const { mutateAsync: updateUserRoles } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultRoles } = + useUpdateDefaultDelegationAccessQuery(); + + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultRoles + : updateUserRoles; + const formattedAssignedEntities: EntitiesOption[] = React.useMemo(() => { if (!role || !role.entity_names || !role.entity_ids) { return []; @@ -85,7 +108,7 @@ export const UpdateEntitiesDrawer = ({ onClose, open, role }: Props) => { role!.entity_type ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles!, entity_access: entityAccess, }); diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx index 4b4f96f7646..8ebc5444dac 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx @@ -23,6 +23,7 @@ const props = { onSuccess: vi.fn(), open: true, role: mockRole, + username: 'test_user', }; const queryMocks = vi.hoisted(() => ({ diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx index bf6b3ef3ec4..5b488887e75 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx @@ -1,6 +1,9 @@ import { + delegationQueries, iamQueries, useAccountRoles, + useQueryClient, + useUpdateDefaultDelegationAccessQuery, useUserRolesMutation, } from '@linode/queries'; import { @@ -12,7 +15,6 @@ import { } from '@linode/ui'; import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; -import { useQueryClient } from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; import React, { useEffect, useState } from 'react'; @@ -22,6 +24,7 @@ import { Link } from 'src/components/Link'; import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; import { AssignSingleRole } from 'src/features/IAM/Users/UserRoles/AssignSingleRole'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { INTERNAL_ERROR_NO_CHANGES_SAVED, ROLES_LEARN_MORE_LINK, @@ -48,13 +51,11 @@ export const AssignNewRoleDrawer = ({ open, }: Props) => { const theme = useTheme(); - const { username } = useParams({ - from: '/iam/users/$username', - }); const queryClient = useQueryClient(); - + const { username } = useParams({ strict: false }); const { data: accountRoles } = useAccountRoles(); - + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const form = useForm({ defaultValues: { roles: [ @@ -95,23 +96,38 @@ export const AssignNewRoleDrawer = ({ return true; }); - }, [accountRoles, assignedRoles, roles]); + }, [accountRoles, assignedRoles]); - const { mutateAsync: updateUserRoles, isPending } = + const { mutateAsync: updateUserRoles, isPending: isUserRolesPending } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultRoles, isPending: isDefaultRolesPending } = + useUpdateDefaultDelegationAccessQuery(); + const onSubmit = async (values: AssignNewRoleFormValues) => { try { - const queryKey = iamQueries.user(username)._ctx.roles.queryKey; - const currentRoles = queryClient.getQueryData(queryKey); - - const mergedRoles = mergeAssignedRolesIntoExistingRoles( - values, - structuredClone(currentRoles) - ); - - await updateUserRoles(mergedRoles); - + if (isDefaultDelegationRolesForChildAccount) { + const currentDefaultRoles = queryClient.getQueryData( + delegationQueries.defaultAccess.queryKey + ); + const mergedDefaultRoles = mergeAssignedRolesIntoExistingRoles( + values, + structuredClone(currentDefaultRoles) + ); + await updateDefaultRoles(mergedDefaultRoles); + } else { + if (!username) { + return; + } + const queryKey = iamQueries.user(username ?? '')._ctx.roles.queryKey; + const currentRoles = queryClient.getQueryData(queryKey); + + const mergedRoles = mergeAssignedRolesIntoExistingRoles( + values, + structuredClone(currentRoles) + ); + await updateUserRoles(mergedRoles); + } enqueueSnackbar(`Roles added.`, { variant: 'success' }); handleClose(); } catch (error) { @@ -135,7 +151,15 @@ export const AssignNewRoleDrawer = ({ }, [open, reset]); return ( - +
    {formState.errors.root?.message && ( @@ -143,9 +167,9 @@ export const AssignNewRoleDrawer = ({ )} - Select a role you want to assign to a user. Some roles require - selecting entities they should apply to. Configure the first role - and continue adding roles or save the assignment.{' '} + {isDefaultDelegationRolesForChildAccount + ? 'Select roles to be assigned to new delegate users by default. Some roles require selecting entities they should apply to. Configure the first role and continue adding roles or save the assignment.' + : 'Select a role you want to assign to a user. Some roles require selecting entities they should apply to. Configure the first role and continue adding roles or save the assignment.'}{' '} Learn more about roles and permissions @@ -195,9 +219,14 @@ export const AssignNewRoleDrawer = ({ [ 'account_linode_creator', 'account_firewall_creator', ], - entity_access: [ - { - id: 1, - type: 'linode' as const, - roles: ['linode_contributor'], - }, - { - id: 1, - type: 'firewall' as const, - roles: ['firewall_admin'], - }, - ], + entity_access: [], }; return makeResponse(mockDefaultAccess); diff --git a/packages/queries/src/iam/iam.ts b/packages/queries/src/iam/iam.ts index 935ca3d5629..237958e52e3 100644 --- a/packages/queries/src/iam/iam.ts +++ b/packages/queries/src/iam/iam.ts @@ -30,15 +30,23 @@ export const useAccountRoles = (enabled = true) => { }); }; -export const useUserRolesMutation = (username: string) => { +export const useUserRolesMutation = (username: string | undefined) => { const queryClient = useQueryClient(); + return useMutation({ - mutationFn: (data) => updateUserRoles(username, data), + mutationFn: (data) => { + if (!username) { + throw new Error('Username is required'); + } + return updateUserRoles(username, data); + }, onSuccess: (role) => { - queryClient.setQueryData( - iamQueries.user(username)._ctx.roles.queryKey, - role, - ); + if (username) { + queryClient.setQueryData( + iamQueries.user(username)._ctx.roles.queryKey, + role, + ); + } }, }); }; From 67ff37f443aa3b9914b779cf31de187a513cb190 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:25:32 +0100 Subject: [PATCH 29/39] fix: [UIE-9267] - IAM: VPC assign linode permission fix (#13030) * fix: [UIE-9267] - IAM: VPC assign linode permission fix * Added changeset: IAM: Linodes without required permissions visible and selectable in Assign/Unassign Linodes selector * fix: [UIE-9267] - IAM: unit test fix * fix: [UIE-9267] - IAM: refactoring --- .../pr-13030-fixed-1761674529303.md | 5 ++ .../SubnetAssignLinodesDrawer.test.tsx | 27 ++++++++++ .../VPCDetail/SubnetAssignLinodesDrawer.tsx | 54 ++++++++++--------- 3 files changed, 61 insertions(+), 25 deletions(-) create mode 100644 packages/manager/.changeset/pr-13030-fixed-1761674529303.md diff --git a/packages/manager/.changeset/pr-13030-fixed-1761674529303.md b/packages/manager/.changeset/pr-13030-fixed-1761674529303.md new file mode 100644 index 00000000000..19951a077c4 --- /dev/null +++ b/packages/manager/.changeset/pr-13030-fixed-1761674529303.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM: Linodes without required permissions visible and selectable in Assign/Unassign Linodes selector ([#13030](https://github.com/linode/manager/pull/13030)) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index b744e8f27cb..a920de4eaea 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -17,6 +17,17 @@ const queryMocks = vi.hoisted(() => ({ useFirewallSettingsQuery: vi.fn().mockReturnValue({}), })); +const iamMocks = vi.hoisted(() => ({ + usePermissions: vi.fn().mockReturnValue({ data: { update_vpc: true } }), + useQueryWithPermissions: vi.fn().mockReturnValue({ + data: [], + isLoading: false, + error: null, + isError: false, + hasFiltered: false, + }), +})); + vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { @@ -25,6 +36,11 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: iamMocks.usePermissions, + useQueryWithPermissions: iamMocks.useQueryWithPermissions, +})); + const props = { isFetching: false, onClose: vi.fn(), @@ -48,6 +64,17 @@ describe('Subnet Assign Linodes Drawer', () => { region: props.vpcRegion, }); + beforeEach(() => { + // Set up the default mock to return the linode + iamMocks.useQueryWithPermissions.mockReturnValue({ + data: [linode], + isLoading: false, + error: null, + isError: false, + hasFiltered: false, + }); + }); + server.use( http.get('*/linode/instances', () => { return HttpResponse.json(makeResourcePage([linode])); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index ada602f3e86..c8ff57284f2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -149,40 +149,44 @@ export const SubnetAssignLinodesDrawer = ( const [allowPublicIPv6Access, setAllowPublicIPv6Access] = React.useState(false); - const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId); - // TODO: change update_linode to create_linode_config_profile_interface once it's available - // TODO: change delete_linode to delete_linode_config_profile_interface once it's available - // TODO: refactor useQueryWithPermissions once API filter is available - const { data: filteredLinodes } = useQueryWithPermissions( - useAllLinodesQuery(), - 'linode', - ['update_linode', 'delete_linode'], + // We only want the linodes from the same region as the VPC + const query = useAllLinodesQuery( + {}, + { + region: vpcRegion, + }, open ); - const userCanAssignLinodes = - permissions?.update_vpc && filteredLinodes?.length > 0; - const downloadCSV = async () => { - await getCSVData(); + // getCSVData + await query.refetch(); csvRef.current.link.click(); }; - // We only want the linodes from the same region as the VPC - const { data: linodes, refetch: getCSVData } = useAllLinodesQuery( - {}, - { - region: vpcRegion, - } - ); + const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId); + // TODO: change update_linode to create_linode_config_profile_interface once it's available + // TODO: change delete_linode to delete_linode_config_profile_interface once it's available + // TODO: refactor useQueryWithPermissions once API filter is available + const { data: filteredLinodes, isLoading: isLoadingFilteredLinodes } = + useQueryWithPermissions( + query, + 'linode', + ['update_linode', 'delete_linode'], + open + ); + const userCanAssignLinodes = + permissions?.update_vpc && filteredLinodes?.length > 0; // We need to filter to the linodes from this region that are not already // assigned to this subnet const findUnassignedLinodes = React.useCallback(() => { - return linodes?.filter((linode) => { + if (!filteredLinodes) return []; + + return filteredLinodes?.filter((linode) => { return !subnet?.linodes.some((linodeInfo) => linodeInfo.id === linode.id); }); - }, [subnet, linodes]); + }, [subnet, filteredLinodes]); const [linodeOptionsToAssign, setLinodeOptionsToAssign] = React.useState< Linode[] @@ -192,10 +196,10 @@ export const SubnetAssignLinodesDrawer = ( // and update that list whenever this subnet or the list of all linodes in this subnet's region changes. This takes // care of the MUI invalid value warning that was occurring before in the Linodes autocomplete [M3-6752] React.useEffect(() => { - if (linodes) { + if (filteredLinodes) { setLinodeOptionsToAssign(findUnassignedLinodes() ?? []); } - }, [linodes, setLinodeOptionsToAssign, findUnassignedLinodes]); + }, [filteredLinodes, setLinodeOptionsToAssign, findUnassignedLinodes]); // Determine the configId based on the number of configurations function getConfigId(inputs: { @@ -551,7 +555,7 @@ export const SubnetAssignLinodesDrawer = ( try { const data = await getAllLinodeConfigs(linode.id); setLinodeConfigs(data); - } catch (errors) { + } catch { // force error to appear at top of drawer setAssignLinodesErrors({ none: 'Could not load configurations for selected linode', @@ -585,7 +589,7 @@ export const SubnetAssignLinodesDrawer = ( return ( Date: Wed, 29 Oct 2025 19:56:11 +0530 Subject: [PATCH 30/39] fix: [M3-10689] - MTC supported regions ids for MTC Linode Migrations (#13026) * Fix invalid oslo region id and update MTC_SUPPORTED_REGIONS * Add regions mock data for us-iad-2 * Update comment * Added changeset: Update `MTC_SUPPORTED_REGIONS` to include valid region IDs (`no-osl-1`, `us-iad`, `us-iad-2`) and replaced invalid `no-east` * Extend mtc2025 feature flag to support mtc supported regions * Revert the last commit to keep this branch in the working state This reverts commit c180d98ae5fab4e9a2b0e8d46cde2aa6ae998820. * Add new flag and extend it to support MTC regions --- .../pr-13026-fixed-1761637569095.md | 5 +++ .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 - packages/manager/src/featureFlags.ts | 14 +++++++- .../KubernetesPlansPanel.tsx | 2 +- .../Linodes/MigrateLinode/ConfigureForm.tsx | 7 ++-- .../components/PlansPanel/PlansPanel.tsx | 2 +- .../components/PlansPanel/constants.ts | 4 --- packages/manager/src/mocks/serverHandlers.ts | 7 ++-- .../utilities/src/__data__/regionsData.ts | 33 ++++++++++++++++++- 9 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 packages/manager/.changeset/pr-13026-fixed-1761637569095.md diff --git a/packages/manager/.changeset/pr-13026-fixed-1761637569095.md b/packages/manager/.changeset/pr-13026-fixed-1761637569095.md new file mode 100644 index 00000000000..518d6554887 --- /dev/null +++ b/packages/manager/.changeset/pr-13026-fixed-1761637569095.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Fixed +--- + +Add new `mtc` feature flag, extend it to support valid regions for MTC Linode Migration, and replace the invalid region ID `no-east` ([#13026](https://github.com/linode/manager/pull/13026)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index dd1bda8e898..f08b7cea774 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -35,7 +35,6 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, { flag: 'lkeEnterprise2', label: 'LKE-Enterprise' }, - { flag: 'mtc2025', label: 'MTC 2025' }, { flag: 'nodebalancerIpv6', label: 'NodeBalancer Dual Stack (IPv6)' }, { flag: 'nodebalancerVpc', label: 'NodeBalancer-VPC Integration' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 358108f3acc..c0aa0ccf1e2 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -1,4 +1,5 @@ import type { OCA } from './features/OneClickApps/types'; +import type { Region } from '@linode/api-v4'; import type { CloudPulseServiceType, TPAProvider, @@ -149,6 +150,17 @@ interface LimitsEvolution { requestForIncreaseDisabledForInternalAccountsOnly: boolean; } +interface MTC { + /** + * Whether the MTC feature is enabled. + */ + enabled: boolean; + /** + * Region IDs where MTC is supported (Only used for Linode Migration region dropdown). + */ + supportedRegions: Region['id'][]; +} + export interface Flags { acceleratedPlans: AcceleratedPlansFlag; aclp: AclpFlag; @@ -193,7 +205,7 @@ export interface Flags { mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; metadata: boolean; - mtc2025: boolean; + mtc: MTC; nodebalancerIpv6: boolean; nodebalancerVpc: boolean; objectStorageGen2: BaseFeatureFlag; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 493d3b2b559..6ded2e38371 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -87,7 +87,7 @@ export const KubernetesPlansPanel = (props: Props) => { const _types = types.filter((type) => { // Do not display MTC plans if the feature flag is not enabled. - if (!flags.mtc2025 && isMTCPlan(type)) { + if (!flags.mtc?.enabled && isMTCPlan(type)) { return false; } diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index b998ebfcfe9..2156ec17e8f 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import { Flag } from 'src/components/Flag'; import { PlacementGroupsSelect } from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { MTC_SUPPORTED_REGIONS } from 'src/features/components/PlansPanel/constants'; import { NO_PLACEMENT_GROUPS_IN_SELECTED_REGION_MESSAGE } from 'src/features/PlacementGroups/constants'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -155,9 +154,9 @@ export const ConfigureForm = React.memo((props: Props) => { return false; } - // If mtc2025 flag is enabled, apply MTC region filtering. - if (flags.mtc2025) { - const isMtcRegion = MTC_SUPPORTED_REGIONS.includes(eachRegion.id); + // If mtc flag is enabled, apply MTC region filtering. + if (flags.mtc?.enabled) { + const isMtcRegion = flags.mtc.supportedRegions.includes(eachRegion.id); // For MTC Linodes, only show MTC regions. // For non-MTC Linodes, exclude MTC regions. diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 5732c5b6df3..4a38be20d5d 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -117,7 +117,7 @@ export const PlansPanel = (props: PlansPanelProps) => { } // Do not display MTC plans if the feature flag is not enabled. - if (!flags.mtc2025 && isMTCPlan(type)) { + if (!flags.mtc?.enabled && isMTCPlan(type)) { return false; } diff --git a/packages/manager/src/features/components/PlansPanel/constants.ts b/packages/manager/src/features/components/PlansPanel/constants.ts index 3e0a58f6356..748c923288e 100644 --- a/packages/manager/src/features/components/PlansPanel/constants.ts +++ b/packages/manager/src/features/components/PlansPanel/constants.ts @@ -35,10 +35,6 @@ export const ACCELERATED_COMPUTE_INSTANCES_LINK = // List of plan types that belong to the MTC plan group. export const MTC_AVAILABLE_PLAN_TYPES = ['g8-premium-128-ht']; -// Only use this for the RegionSelect dropdown in the Linode Migration flow and for mocks in serverHandlers.ts -// Note: We need to find a way to determine MTC supported regions from the API rather than relying on client-side hardcoded values here. -export const MTC_SUPPORTED_REGIONS = ['no-east', 'us-iad']; - export const DEDICATED_512_GB_PLAN: ExtendedType = { accelerated_devices: 0, addons: { diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 5c1f400e965..14d3237a9b9 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -136,7 +136,6 @@ import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; import { userAccountPermissionsFactory } from 'src/factories/userAccountPermissions'; import { userEntityPermissionsFactory } from 'src/factories/userEntityPermissions'; import { userRolesFactory } from 'src/factories/userRoles'; -import { MTC_SUPPORTED_REGIONS } from 'src/features/components/PlansPanel/constants'; import type { AccountMaintenance, @@ -814,7 +813,7 @@ export const handlers = [ }), linodeFactory.build({ label: 'mtc-custom-plan-linode-2', - region: 'no-east', + region: 'no-osl-1', type: 'g8-premium-128-ht', id: 1002, }), @@ -1006,7 +1005,7 @@ export const handlers = [ id, backups: { enabled: false }, label: 'mtc-custom-plan-linode-2', - region: 'no-east', + region: 'no-osl-1', type: 'g8-premium-128-ht', }), ]; @@ -2656,7 +2655,7 @@ export const handlers = [ }), // MTC plans are region-specific. The supported regions list below is hardcoded for testing purposes and will expand over time. // The availability of MTC plans is fully handled by this endpoint, which determines the plan's availability status (true/false) for the selected region. - ...(MTC_SUPPORTED_REGIONS.includes(selectedRegion) + ...(['no-osl-1', 'us-iad', 'us-iad-2'].includes(selectedRegion) ? [ regionAvailabilityFactory.build({ available: true, // In supported regions, this can be `true` (plan available) or `false` (plan sold-out). diff --git a/packages/utilities/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts index a65e1bb0729..13bb19985e6 100644 --- a/packages/utilities/src/__data__/regionsData.ts +++ b/packages/utilities/src/__data__/regionsData.ts @@ -133,6 +133,37 @@ export const regions: Region[] = [ ], }, }, + { + capabilities: [ + 'Linodes', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + 'Maintenance Policy', + ], + country: 'us', + id: 'us-iad-2', + label: 'Washington 2, DC', + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, + resolvers: { + ipv4: '139.144.192.62, 139.144.192.60, 139.144.192.61, 139.144.192.53, 139.144.192.54, 139.144.192.67, 139.144.192.69, 139.144.192.66, 139.144.192.52, 139.144.192.68', + ipv6: '2600:3c05::f03c:93ff:feb6:43b6, 2600:3c05::f03c:93ff:feb6:4365, 2600:3c05::f03c:93ff:feb6:43c2, 2600:3c05::f03c:93ff:feb6:e441, 2600:3c05::f03c:93ff:feb6:94ef, 2600:3c05::f03c:93ff:feb6:94ba, 2600:3c05::f03c:93ff:feb6:94a8, 2600:3c05::f03c:93ff:feb6:9413, 2600:3c05::f03c:93ff:feb6:9443, 2600:3c05::f03c:93ff:feb6:94e0', + }, + site_type: 'core', + status: 'ok', + monitors: { alerts: ['Linodes', 'Object Storage'], metrics: ['Linodes'] }, + }, { capabilities: [ 'Linodes', @@ -149,7 +180,7 @@ export const regions: Region[] = [ 'Placement Group', ], country: 'no', - id: 'no-east', + id: 'no-osl-1', label: 'Oslo', placement_group_limits: { maximum_linodes_per_pg: 10, From 756ab86ca6d4e68f4f20b02717b2b3d2ec7c9753 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:02:04 -0400 Subject: [PATCH 31/39] fix: [M3-10688] - Add ability to enter migration queue (#13024) * hotfix: [M3-10688] - Add ability to enter migration queue * Bump version and changelog * Revert hotfix changes and add changeset * Add feature flag --------- Co-authored-by: Jaalah Ramos --- .../pr-13024-fixed-1761594892256.md | 5 + .../LinodeMaintenanceBanner.tsx | 150 ++++++++++++++---- .../components/ExtraPresetMaintenance.tsx | 3 + packages/manager/src/featureFlags.ts | 1 + 4 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 packages/manager/.changeset/pr-13024-fixed-1761594892256.md diff --git a/packages/manager/.changeset/pr-13024-fixed-1761594892256.md b/packages/manager/.changeset/pr-13024-fixed-1761594892256.md new file mode 100644 index 00000000000..7265838a89c --- /dev/null +++ b/packages/manager/.changeset/pr-13024-fixed-1761594892256.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Add self-service maintenance action in LinodeMaintenanceBanner for power_off_on and include all maintenance types in dev tools preset ([#13024](https://github.com/linode/manager/pull/13024)) diff --git a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx index 9240f0ddb50..e1991b8848a 100644 --- a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx @@ -1,10 +1,15 @@ +import { scheduleOrQueueMigration } from '@linode/api-v4/lib/linodes'; import { useAllAccountMaintenanceQuery } from '@linode/queries'; -import { Notice, Typography } from '@linode/ui'; +import { ActionsPanel, LinkButton, Notice, Typography } from '@linode/ui'; +import { useDialog } from '@linode/utilities'; +import { useSnackbar } from 'notistack'; import React from 'react'; import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities'; +import { useFlags } from 'src/hooks/useFlags'; import { isPlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; +import { ConfirmationDialog } from '../ConfirmationDialog/ConfirmationDialog'; import { DateTimeDisplay } from '../DateTimeDisplay'; import { Link } from '../Link'; @@ -13,6 +18,8 @@ interface Props { } export const LinodeMaintenanceBanner = ({ linodeId }: Props) => { + const flags = useFlags(); + const { enqueueSnackbar } = useSnackbar(); const { data: allMaintenance } = useAllAccountMaintenanceQuery( {}, PENDING_MAINTENANCE_FILTER, @@ -31,45 +38,122 @@ export const LinodeMaintenanceBanner = ({ linodeId }: Props) => { const maintenanceStartTime = linodeMaintenance?.start_time || linodeMaintenance?.when; + const { closeDialog, dialog, handleError, openDialog, submitDialog } = + useDialog((id: number) => scheduleOrQueueMigration(id)); + + const isScheduled = Boolean(maintenanceStartTime); + + const actionLabel = isScheduled + ? 'enter the migration queue' + : 'schedule your migration'; + const showMigrateAction = + Boolean(flags.vmHostMaintenance?.hasQueue) && + linodeId !== undefined && + linodeMaintenance?.type === 'power_off_on'; + + const onSubmit = () => { + if (!linodeId) { + return; + } + submitDialog(linodeId) + .then(() => { + const successMessage = isScheduled + ? 'Your Linode has been entered into the migration queue.' + : 'Your migration has been scheduled.'; + enqueueSnackbar(successMessage, { variant: 'success' }); + }) + .catch(() => { + const errorMessage = isScheduled + ? 'An error occurred entering the migration queue.' + : 'An error occurred scheduling your migration.'; + handleError(errorMessage); + }); + }; + + const actions = () => ( + + ); + if (!linodeMaintenance) return null; return ( - - - Linode {linodeMaintenance.entity.label} {linodeMaintenance.description}{' '} - maintenance {maintenanceTypeLabel} will begin{' '} - - {maintenanceStartTime ? ( + <> + + + Linode {linodeMaintenance.entity.label}{' '} + {linodeMaintenance.description} maintenance {maintenanceTypeLabel}{' '} + will begin{' '} + + {maintenanceStartTime ? ( + <> + ({ + font: theme.font.bold, + })} + value={maintenanceStartTime} + />{' '} + at{' '} + ({ + font: theme.font.bold, + })} + value={maintenanceStartTime} + /> + + ) : ( + 'soon' + )} + + . For more details, view{' '} + + Account Maintenance + + {showMigrateAction ? ( <> - ({ - font: theme.font.bold, - })} - value={maintenanceStartTime} - />{' '} - at{' '} - ({ - font: theme.font.bold, - })} - value={maintenanceStartTime} - /> + {' or '} + openDialog(linodeId)}> + {actionLabel} + + {' now.'} ) : ( - 'soon' + '.' )} - - . For more details, view{' '} - + + {showMigrateAction && ( + closeDialog()} + open={dialog.isOpen} + title="Confirm Migration" > - Account Maintenance - - . - - + + Are you sure you want to{' '} + {isScheduled + ? 'enter the migration queue now' + : 'schedule your migration now'} + ? + +
    + )} + ); }; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx index d1266c97001..c257d22db49 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx @@ -109,6 +109,9 @@ const renderMaintenanceFields = ( + + + , diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index c0aa0ccf1e2..b96989841a2 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -71,6 +71,7 @@ interface LinodeInterfacesFlag extends BaseFeatureFlag { interface VMHostMaintenanceFlag extends BaseFeatureFlag { beta: boolean; + hasQueue?: boolean; new: boolean; } From 3de19a3909880c65ecf206dd57a4a51ef64fdfc5 Mon Sep 17 00:00:00 2001 From: smans-akamai Date: Wed, 29 Oct 2025 15:32:06 -0400 Subject: [PATCH 32/39] chore: [UIE-8745] - Add missing changeset for Table and Paginator web component integration in DBaaS (#13035) * chore: [UIE-8745] - Add missing changeset for Table and Paginator web component integration in DBaaS * Adding actual changeset file pointing to previous PR with missing changeset --- .../manager/.changeset/pr-13035-changed-1761764242108.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13035-changed-1761764242108.md diff --git a/packages/manager/.changeset/pr-13035-changed-1761764242108.md b/packages/manager/.changeset/pr-13035-changed-1761764242108.md new file mode 100644 index 00000000000..1112c001944 --- /dev/null +++ b/packages/manager/.changeset/pr-13035-changed-1761764242108.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Replace table and paginator in DBaaS with CDS web components ([#12989](https://github.com/linode/manager/pull/12989)) From 40219cee08f0b3d05fcaa227291b3883d48430c8 Mon Sep 17 00:00:00 2001 From: Ankita Date: Thu, 30 Oct 2025 01:18:46 +0530 Subject: [PATCH 33/39] upcoming: [DI-27803] - Add new optional NodeBalancers filter in Nodebalancer-Firewall dashboard (#13029) * [DI-27803] - Add new optional nodebalancers filter in nodebalancer firewall dashboard * [DI-27803] - update as latest aclp dev * upcoming: [DI-27803] - cleanup * upcoming: [DI-27803] - more cleanup * upcoming: [DI-27803] - Pr comments * upcoming: [DI-27803] - Pass order by * upcoming: [DI-27803] - Minor cleanup * upcoming: [DI-27803] - Add temporary integration in firewalls page for reviewer * upcoming: [DI-27803] - Add changeset * upcoming: [DI-27803] - use existing style const * upcoming: [DI-27803] - Remove temporary integration in service page --- ...r-13029-upcoming-features-1761669638634.md | 5 + .../Dashboard/CloudPulseDashboard.tsx | 9 +- .../CloudPulse/Utils/FilterBuilder.test.ts | 124 ++++++- .../CloudPulse/Utils/FilterBuilder.ts | 91 ++++- .../features/CloudPulse/Utils/FilterConfig.ts | 22 ++ .../features/CloudPulse/Utils/constants.ts | 2 + .../src/features/CloudPulse/Utils/models.ts | 6 +- .../features/CloudPulse/Utils/utils.test.ts | 15 + .../src/features/CloudPulse/Utils/utils.ts | 13 + .../shared/CloudPulseComponentRenderer.tsx | 5 + .../CloudPulseDashboardFilterBuilder.test.tsx | 3 +- .../CloudPulseDashboardFilterBuilder.tsx | 39 ++ ...dPulseFirewallNodebalancersSelect.test.tsx | 348 ++++++++++++++++++ .../CloudPulseFirewallNodebalancersSelect.tsx | 248 +++++++++++++ .../shared/CloudPulseRegionSelect.tsx | 5 +- 15 files changed, 922 insertions(+), 13 deletions(-) create mode 100644 packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx diff --git a/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md b/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md new file mode 100644 index 00000000000..444f9327d46 --- /dev/null +++ b/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Add optional-filter component at `CloudPulseFirewallNodebalancersSelect.tsx` and integrate it with existing firewall-nodebalancer filters ([#13029](https://github.com/linode/manager/pull/13029)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index d17871c1663..e8148e9fcc6 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -11,7 +11,10 @@ import { import { RESOURCE_FILTER_MAP } from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; -import { getResourcesFilterConfig } from '../Utils/utils'; +import { + getAssociatedEntityType, + getResourcesFilterConfig, +} from '../Utils/utils'; import { renderPlaceHolder, RenderWidgets, @@ -114,9 +117,9 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { // Get the resources filter configuration for the dashboard const resourcesFilterConfig = getResourcesFilterConfig(dashboardId); - const associatedEntityType = - resourcesFilterConfig?.associatedEntityType ?? 'both'; const filterFn = resourcesFilterConfig?.filterFn; + // Get the associated entity type for the dashboard + const associatedEntityType = getAssociatedEntityType(dashboardId); const { data: resourceList, diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 8f326982c80..798a0e85fec 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -1,4 +1,5 @@ import { databaseQueries } from '@linode/queries'; +import { nodeBalancerFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { @@ -12,9 +13,11 @@ import { deepEqual, filterBasedOnConfig, filterEndpointsUsingRegion, + filterFirewallNodebalancers, filterUsingDependentFilters, getEndpointsProperties, getFilters, + getFirewallNodebalancersProperties, getTextFilterProperties, } from './FilterBuilder'; import { @@ -43,7 +46,9 @@ const dbaasConfig = FILTER_CONFIG.get(1); const nodeBalancerConfig = FILTER_CONFIG.get(3); -const firewallConfig = FILTER_CONFIG.get(4); +const linodeFirewallConfig = FILTER_CONFIG.get(4); + +const nodebalancerFirewallConfig = FILTER_CONFIG.get(8); const dbaasDashboard = dashboardFactory.build({ service_type: 'dbaas', id: 1 }); @@ -135,7 +140,7 @@ it('test getResourceSelectionProperties method', () => { }); it('test getResourceSelectionProperties method for linode-firewall', () => { - const resourceSelectionConfig = firewallConfig?.filters.find( + const resourceSelectionConfig = linodeFirewallConfig?.filters.find( (filterObj) => filterObj.name === 'Firewalls' ); @@ -426,7 +431,7 @@ it('test getTextFilterProperties method for port', () => { }); it('test getTextFilterProperties method for interface_id', () => { - const interfaceIdFilterConfig = firewallConfig?.filters.find( + const interfaceIdFilterConfig = linodeFirewallConfig?.filters.find( (filterObj) => filterObj.name === 'Interface IDs' ); @@ -488,6 +493,49 @@ it('test getEndpointsProperties method', () => { expect(xFilter).toEqual({ region: 'us-east' }); } }); +it('test getFirewallNodebalancersProperties', () => { + const nodebalancersConfig = nodebalancerFirewallConfig?.filters.find( + (filterObj) => filterObj.name === 'NodeBalancers' + ); + + expect(nodebalancersConfig).toBeDefined(); + + if (nodebalancersConfig) { + const nodebalancersProperties = getFirewallNodebalancersProperties( + { + config: nodebalancersConfig, + dashboard: dashboardFactory.build({ service_type: 'firewall', id: 8 }), + dependentFilters: { + resource_id: '1', + associated_entity_region: 'us-east', + }, + isServiceAnalyticsIntegration: false, + }, + vi.fn() + ); + const { + label, + disabled, + selectedDashboard, + savePreferences, + handleNodebalancersSelection, + defaultValue, + xFilter, + } = nodebalancersProperties; + + expect(nodebalancersProperties).toBeDefined(); + expect(label).toEqual(nodebalancersConfig.configuration.name); + expect(selectedDashboard.service_type).toEqual('firewall'); + expect(savePreferences).toEqual(true); + expect(disabled).toEqual(false); + expect(handleNodebalancersSelection).toBeDefined(); + expect(defaultValue).toEqual(undefined); + expect(xFilter).toEqual({ + resource_id: '1', + associated_entity_region: 'us-east', + }); + } +}); it('test getFiltersForMetricsCallFromCustomSelect method', () => { const result = getMetricsCallCustomFilters( @@ -669,6 +717,76 @@ describe('filterEndpointsUsingRegion', () => { }); }); +describe('filterFirewallNodebalancers', () => { + const mockData = [ + nodeBalancerFactory.build({ + id: 1, + label: 'nodebalancer-1', + region: 'us-east', + }), + nodeBalancerFactory.build({ + id: 2, + label: 'nodebalancer-2', + region: 'us-west', + }), + ]; + const mockFirewalls: CloudPulseResources[] = [ + { + id: '1', + label: 'firewall-1', + entities: { '1': 'nodebalancer-1' }, + }, + ]; + + it('should return undefined if data is undefined', () => { + expect( + filterFirewallNodebalancers( + undefined, + { associated_entity_region: 'us-east', resource_id: '1' }, + mockFirewalls + ) + ).toEqual(undefined); + }); + + it('should return undefined if xFilter/firewalls is empty or undefined', () => { + const result = filterFirewallNodebalancers( + mockData, + undefined, + mockFirewalls + ); + const result2 = filterFirewallNodebalancers(mockData, {}, mockFirewalls); + const result3 = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + [] + ); + const result4 = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + undefined + ); + expect(result).toEqual(undefined); + expect(result2).toEqual(undefined); + expect(result3).toEqual(undefined); + expect(result4).toEqual(undefined); + }); + + it('should filter nodebalancers based on xFilter', () => { + const result = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + mockFirewalls + ); + expect(result).toEqual([ + { + id: '1', + label: 'nodebalancer-1', + associated_entity_region: 'us-east', + }, + ]); + }); +}); + describe('filterBasedOnConfig', () => { const config: CloudPulseServiceTypeFilters = { configuration: { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 17d7895b956..6ec4a6480b5 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -8,6 +8,7 @@ import { } from './constants'; import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; +import { getAssociatedEntityType } from './utils'; import type { CloudPulseMetricsFilter, @@ -16,6 +17,10 @@ import type { import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseEndpointsSelectProps } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; +import type { + CloudPulseFirewallNodebalancersSelectProps, + CloudPulseNodebalancers, +} from '../shared/CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect'; import type { @@ -36,6 +41,7 @@ import type { Dashboard, DateTimeWithPreset, Filters, + NodeBalancer, TimeDuration, } from '@linode/api-v4'; @@ -183,7 +189,7 @@ export const getResourcesProperties = ( resourceType: dashboard.service_type, savePreferences: !isServiceAnalyticsIntegration, xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), - associatedEntityType: config.configuration.associatedEntityType ?? 'both', + associatedEntityType: getAssociatedEntityType(dashboard.id), filterFn: config.configuration.filterFn, }; }; @@ -408,6 +414,47 @@ export const getEndpointsProperties = ( }; }; +/** + * + * @param props The cloudpulse filter properties selected so far + * @param handleFirewallNodebalancersChange The callback function when selection of nodebalancers changes + * @returns CloudPulseFirewallNodebalancersSelectProps + */ +export const getFirewallNodebalancersProperties = ( + props: CloudPulseFilterProperties, + handleFirewallNodebalancersChange: ( + nodebalancers: CloudPulseNodebalancers[], + savePref?: boolean + ) => void +): CloudPulseFirewallNodebalancersSelectProps => { + const { filterKey, name: label, placeholder } = props.config.configuration; + const { + config, + dashboard, + dependentFilters, + isServiceAnalyticsIntegration, + preferences, + shouldDisable, + } = props; + return { + defaultValue: preferences?.[config.configuration.filterKey], + selectedDashboard: dashboard, + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard, + preferences + ), + handleNodebalancersSelection: handleFirewallNodebalancersChange, + label, + placeholder, + savePreferences: !isServiceAnalyticsIntegration, + xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + isOptional: config.configuration.isOptional, + }; +}; /** * This function helps in builder the xFilter needed to passed in a apiV4 call * @@ -769,3 +816,45 @@ export const filterEndpointsUsingRegion = ( return data.filter(({ region }) => region === regionFromFilter); }; + +/** + * + * @param data The nodebalancers for which the filter needs to be applied + * @param xFilter The selected filters that will be used to filter the nodebalancers + * @param firewalls The firewalls for which the filter needs to be applied + * @returns The filtered nodebalancers + */ + +export const filterFirewallNodebalancers = ( + data?: NodeBalancer[], + xFilter?: CloudPulseMetricsFilter, + firewalls?: CloudPulseResources[] +): CloudPulseNodebalancers[] | undefined => { + // If data is undefined or xFilter/firewalls is undefined or empty, return undefined + if (!data || !xFilter || !Object.keys(xFilter).length || !firewalls?.length) { + return undefined; + } + + // Map the nodebalancers to the CloudPulseNodebalancers interface + const nodebalancers: CloudPulseNodebalancers[] = data.map((nodebalancer) => ({ + id: String(nodebalancer.id), + label: nodebalancer.label, + associated_entity_region: nodebalancer.region, + })); + + const firewallObj = firewalls.find( + (firewall) => firewall.id === String(xFilter[RESOURCE_ID]) + ); + + return nodebalancers.filter((nodebalancer) => { + return Object.entries(xFilter).every(([key, filterValue]) => { + // If the filter key is the resource id, check if the nodebalancer is associated with the selected firewall + if (key === RESOURCE_ID) { + return firewallObj?.entities?.[nodebalancer.id]; + } + const nodebalancerValue = + nodebalancer[key as keyof CloudPulseNodebalancers]; + return nodebalancerValue === filterValue; + }); + }); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index f8aa2bf58aa..a68c9968303 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -5,6 +5,7 @@ import { queryFactory } from 'src/queries/cloudpulse/queries'; import { ENDPOINT, INTERFACE_IDS_PLACEHOLDER_TEXT, + NODEBALANCER_ID, PARENT_ENTITY_REGION, REGION, RESOURCE_ID, @@ -322,6 +323,7 @@ export const FIREWALL_CONFIG: Readonly = { }, ], serviceType: 'firewall', + associatedEntityType: 'linode', }; export const FIREWALL_NODEBALANCER_CONFIG: Readonly = @@ -362,8 +364,28 @@ export const FIREWALL_NODEBALANCER_CONFIG: Readonly = diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index b7abcf56256..2095c2852be 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -16,6 +16,8 @@ export const PARENT_ENTITY_REGION = 'associated_entity_region'; export const RESOURCES = 'resources'; +export const NODEBALANCER_ID = 'nodebalancer_id'; + export const INTERVAL = 'interval'; export const TIME_DURATION = 'dateTimeDuration'; diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index 4daa996d85f..0386c8bd7a2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -17,6 +17,10 @@ import type { QueryFunction, QueryKey } from '@tanstack/react-query'; * The CloudPulseServiceTypeMap has list of filters to be built for different service types like dbaas, linode etc.,The properties here are readonly as it is only for reading and can't be modified in code */ export interface CloudPulseServiceTypeFilterMap { + /** + * The associated entity type for the service type + */ + readonly associatedEntityType?: AssociatedEntityType; /** * Current capability corresponding to a service type */ @@ -24,9 +28,7 @@ export interface CloudPulseServiceTypeFilterMap { /** * The list of filters for a service type */ - readonly filters: CloudPulseServiceTypeFilters[]; - /** * The service types like dbaas, linode etc., */ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index fb4a91e16ae..657235635bb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -25,6 +25,7 @@ import { arePortsValid, areValidInterfaceIds, filterFirewallResources, + getAssociatedEntityType, getEnabledServiceTypes, getFilteredDimensions, getResourcesFilterConfig, @@ -370,6 +371,20 @@ describe('getEnabledServiceTypes', () => { }); }); + describe('getAssociatedEntityType', () => { + it('should return undefined if the dashboard id is not provided', () => { + expect(getAssociatedEntityType(undefined)).toBeUndefined(); + }); + + it('should return the associated entity type for the linode-firewall dashboard', () => { + expect(getAssociatedEntityType(4)).toBe('linode'); + }); + + it('should return the associated entity type for the nodebalancer-firewall dashboard', () => { + expect(getAssociatedEntityType(8)).toBe('nodebalancer'); + }); + }); + describe('filterFirewallResources', () => { it('should return the filtered firewall resources for linode', () => { const resources = [ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 4d6dd48dd4c..c49c50500ae 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -550,6 +550,19 @@ export const getResourcesFilterConfig = ( )?.configuration; }; +/** + * @param dashboardId The id of the dashboard + * @returns The associated entity type for the dashboard + */ +export const getAssociatedEntityType = ( + dashboardId: number | undefined +): AssociatedEntityType | undefined => { + if (!dashboardId) { + return undefined; + } + return FILTER_CONFIG.get(dashboardId)?.associatedEntityType; +}; + /** * * @param resources Firewall resources diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx index 75ab29be939..0a90f3801d4 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx @@ -6,6 +6,7 @@ import NullComponent from 'src/components/NullComponent'; import { CloudPulseCustomSelect } from './CloudPulseCustomSelect'; import { CloudPulseDateTimeRangePicker } from './CloudPulseDateTimeRangePicker'; import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect'; +import { CloudPulseFirewallNodebalancersSelect } from './CloudPulseFirewallNodebalancersSelect'; import { CloudPulseNodeTypeFilter } from './CloudPulseNodeTypeFilter'; import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect'; @@ -15,6 +16,7 @@ import { CloudPulseTextFilter } from './CloudPulseTextFilter'; import type { CloudPulseCustomSelectProps } from './CloudPulseCustomSelect'; import type { CloudPulseDateTimeRangePickerProps } from './CloudPulseDateTimeRangePicker'; import type { CloudPulseEndpointsSelectProps } from './CloudPulseEndpointsSelect'; +import type { CloudPulseFirewallNodebalancersSelectProps } from './CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseNodeTypeFilterProps } from './CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; import type { CloudPulseResourcesSelectProps } from './CloudPulseResourcesSelect'; @@ -27,6 +29,7 @@ export interface CloudPulseComponentRendererProps { | CloudPulseCustomSelectProps | CloudPulseDateTimeRangePickerProps | CloudPulseEndpointsSelectProps + | CloudPulseFirewallNodebalancersSelectProps | CloudPulseNodeTypeFilterProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps @@ -41,6 +44,7 @@ const Components: { | CloudPulseCustomSelectProps | CloudPulseDateTimeRangePickerProps | CloudPulseEndpointsSelectProps + | CloudPulseFirewallNodebalancersSelectProps | CloudPulseNodeTypeFilterProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps @@ -59,6 +63,7 @@ const Components: { tags: CloudPulseTagsSelect, associated_entity_region: CloudPulseRegionSelect, endpoint: CloudPulseEndpointsSelect, + nodebalancer_id: CloudPulseFirewallNodebalancersSelect, }; const buildComponent = (props: CloudPulseComponentRendererProps) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index 62b50cf23d1..1c7a7fedf16 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -127,11 +127,12 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { emitFilterChange={vi.fn()} handleToggleAppliedFilter={vi.fn()} isServiceAnalyticsIntegration={false} - resource_ids={[1, 2]} + resource_ids={[1]} /> ); expect(getByPlaceholderText('Select a Firewall')).toBeVisible(); expect(getByPlaceholderText('Select a NodeBalancer Region')).toBeVisible(); + expect(getByPlaceholderText('Select NodeBalancers')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 3455340bba4..4b033145e94 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -14,6 +14,7 @@ import { FIREWALL, INTERFACE_ID, NODE_TYPE, + NODEBALANCER_ID, PARENT_ENTITY_REGION, PORT, REGION, @@ -25,6 +26,7 @@ import { getCustomSelectProperties, getEndpointsProperties, getFilters, + getFirewallNodebalancersProperties, getNodeTypeProperties, getRegionProperties, getResourcesProperties, @@ -38,6 +40,7 @@ import type { CloudPulseMetricsFilter, FilterValueType, } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseNodebalancers } from './CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseTags } from './CloudPulseTagsFilter'; import type { AclpConfig, Dashboard } from '@linode/api-v4'; @@ -244,6 +247,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( } : { [filterKey]: region, + [NODEBALANCER_ID]: undefined, }; emitFilterChangeByFilterKey( filterKey, @@ -266,6 +270,23 @@ export const CloudPulseDashboardFilterBuilder = React.memo( [emitFilterChangeByFilterKey] ); + const handleFirewallNodebalancersChange = React.useCallback( + (nodebalancers: CloudPulseNodebalancers[], savePref: boolean = false) => { + emitFilterChangeByFilterKey( + NODEBALANCER_ID, + nodebalancers.map((nodebalancer) => nodebalancer.id), + nodebalancers.map((nodebalancer) => nodebalancer.label), + savePref, + { + [NODEBALANCER_ID]: nodebalancers.map( + (nodebalancer) => nodebalancer.id + ), + } + ); + }, + [emitFilterChangeByFilterKey] + ); + const handleCustomSelectChange = React.useCallback( ( filterKey: string, @@ -386,6 +407,23 @@ export const CloudPulseDashboardFilterBuilder = React.memo( }, handleEndpointsChange ); + } else if (config.configuration.filterKey === NODEBALANCER_ID) { + return getFirewallNodebalancersProperties( + { + config, + dashboard, + dependentFilters: resource_ids?.length + ? { + ...dependentFilterReference.current, + [RESOURCE_ID]: resource_ids.map(String), + } + : dependentFilterReference.current, + isServiceAnalyticsIntegration, + preferences, + shouldDisable: isError || isLoading, + }, + handleFirewallNodebalancersChange + ); } else { return getCustomSelectProperties( { @@ -409,6 +447,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( handleResourceChange, handleEndpointsChange, handleCustomSelectChange, + handleFirewallNodebalancersChange, isServiceAnalyticsIntegration, preferences, isError, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx new file mode 100644 index 00000000000..f76f36cc241 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx @@ -0,0 +1,348 @@ +import { nodeBalancerFactory } from '@linode/utilities'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { dashboardFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseFirewallNodebalancersSelect } from './CloudPulseFirewallNodebalancersSelect'; + +import type { CloudPulseResources } from './CloudPulseResourcesSelect'; + +const queryMocks = vi.hoisted(() => ({ + useAllNodeBalancersQuery: vi.fn().mockReturnValue({}), + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllNodeBalancersQuery: queryMocks.useAllNodeBalancersQuery, + }; +}); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + +const mockNodebalancerHandler = vi.fn(); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected'; + +const mockNodebalancers = [ + nodeBalancerFactory.build({ + id: 1, + label: 'nodebalancer-1', + region: 'us-east', + }), + nodeBalancerFactory.build({ + id: 2, + label: 'nodebalancer-2', + region: 'us-west', + }), + nodeBalancerFactory.build({ + id: 3, + label: 'nodebalancer-3', + region: 'us-east', + }), +]; + +const mockFirewalls: CloudPulseResources[] = [ + { + id: '1', + label: 'firewall-1', + entities: { '1': 'nodebalancer-1', '3': 'nodebalancer-3' }, + }, + { + id: '2', + label: 'firewall-2', + entities: { '2': 'nodebalancer-2' }, + }, +]; + +const mockDashboard = dashboardFactory.build({ + service_type: 'firewall', + id: 8, +}); + +describe('CloudPulseFirewallNodebalancersSelect component tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with the correct label and placeholder', () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + expect(screen.getByLabelText('NodeBalancers (optional)')).toBeVisible(); + expect(screen.getByPlaceholderText('Select NodeBalancers')).toBeVisible(); + }); + + it('should render disabled component when disabled prop is true', () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + expect(screen.getByTestId('textfield-input')).toBeDisabled(); + }); + + it('should render nodebalancers when data is available', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should show nodebalancers that are associated with the selected firewall + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toBeVisible(); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toBeVisible(); + + // Should not show nodebalancer-2 as it's not associated with firewall-1 + expect( + screen.queryByRole('option', { + name: 'nodebalancer-2', + }) + ).not.toBeInTheDocument(); + }); + + it('should be able to select and deselect the nodebalancers', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: SELECT_ALL }) + ); + + // Check that nodebalancers are selected + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + + // Close the autocomplete to trigger the handler call + await userEvent.click(await screen.findByRole('button', { name: 'Close' })); + + // Should call the handler with the selected nodebalancers + expect(mockNodebalancerHandler).toHaveBeenCalledWith( + [ + { + id: '1', + label: 'nodebalancer-1', + associated_entity_region: 'us-east', + }, + { + id: '3', + label: 'nodebalancer-3', + associated_entity_region: 'us-east', + }, + ], + true + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Deselect All' }) + ); + + // Check that nodebalancers are deselected + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should show appropriate error message on nodebalancers call failure', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: null, + isError: true, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + // The error message should be visible immediately when isError is true + expect(screen.getByText('Failed to fetch NodeBalancers.')).toBeVisible(); + }); + + it('should filter nodebalancers based on xFilter and firewalls', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should only show nodebalancers associated with firewall-1 in us-east + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toBeVisible(); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toBeVisible(); + + // Should not show nodebalancer-2 (associated with firewall-2) + expect( + screen.queryByRole('option', { + name: 'nodebalancer-2', + }) + ).not.toBeInTheDocument(); + }); + + it('should handle default values correctly', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + + const defaultValue = ['1', '3']; // IDs of nodebalancers to select by default + + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should show that nodebalancer-1 and nodebalancer-3 are selected by default + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx new file mode 100644 index 00000000000..0833c8d4d92 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx @@ -0,0 +1,248 @@ +import { useAllNodeBalancersQuery } from '@linode/queries'; +import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; +import { Box } from '@mui/material'; +import React from 'react'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import { PARENT_ENTITY_REGION, RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { deepEqual, filterFirewallNodebalancers } from '../Utils/FilterBuilder'; +import { getAssociatedEntityType } from '../Utils/utils'; +import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; + +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { Dashboard, FilterValue } from '@linode/api-v4'; + +export interface CloudPulseNodebalancers { + /** + * The region of the nodebalancer + */ + associated_entity_region: string; + /** + * The id of the nodebalancer + */ + id: string; + /** + * The label of the nodebalancer + */ + label: string; +} + +export interface CloudPulseFirewallNodebalancersSelectProps { + /** + * The default value of the nodebalancers filter + */ + defaultValue?: Partial; + /** + * Whether the nodebalancers filter is disabled + */ + disabled?: boolean; + /** + * The function to handle the nodebalancers selection + */ + handleNodebalancersSelection: ( + nodebalancers: CloudPulseNodebalancers[], + savePref?: boolean + ) => void; + /** + * Whether the nodebalancers filter is optional + */ + isOptional?: boolean; + /** + * The label of the nodebalancers filter + */ + label: string; + /** + * The placeholder of the nodebalancers filter + */ + placeholder?: string; + /** + * Whether to save the preferences + */ + savePreferences?: boolean; + /** + * The selected dashboard + */ + selectedDashboard: Dashboard; + /** + * The dependent filters of the nodebalancers + */ + xFilter?: CloudPulseMetricsFilter; +} + +export const CloudPulseFirewallNodebalancersSelect = React.memo( + (props: CloudPulseFirewallNodebalancersSelectProps) => { + const { + defaultValue, + disabled, + handleNodebalancersSelection, + label, + placeholder, + savePreferences, + xFilter, + isOptional, + selectedDashboard, + } = props; + + const serviceType = selectedDashboard.service_type; + const region = xFilter?.[PARENT_ENTITY_REGION]; + + // Get the associated entity type for the selected dashboard + const associatedEntityType = getAssociatedEntityType(selectedDashboard.id); + + const { data: firewalls } = useResourcesQuery( + disabled !== undefined ? !disabled : Boolean(region), + serviceType, + {}, + + RESOURCE_FILTER_MAP[serviceType] ?? {}, + associatedEntityType + ); + + const [selectedNodebalancers, setSelectedNodebalancers] = + React.useState(); + + /** + * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below + * When the autocomplete is already closed, we should publish the resources on clear action and deselect action as well since onclose will not be triggered at that time + * When the autocomplete is open, we should not publish any resources on clear action until the autocomplete is close + */ + const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete + + const { + data: nodebalancers, + isError, + isLoading, + } = useAllNodeBalancersQuery( + true, + {}, + { + '+order': 'asc', + '+order_by': 'label', + } + ); + + // Get the list of nodebalancers that are associated with the selected firewall + const getNodebalancersList = React.useMemo< + CloudPulseNodebalancers[] + >(() => { + return ( + filterFirewallNodebalancers(nodebalancers, xFilter, firewalls) ?? [] + ); + }, [firewalls, nodebalancers, xFilter]); + + // Once the data is loaded, set the state variable with value stored in preferences + React.useEffect(() => { + if (disabled && !selectedNodebalancers) { + return; + } + // To save default values, go through side effects + if (!getNodebalancersList || !savePreferences || selectedNodebalancers) { + if (selectedNodebalancers) { + setSelectedNodebalancers([]); + handleNodebalancersSelection([]); + } + } else { + // Get the default nodebalancers from the nodebalancer ids stored in preferences + const defaultNodebalancers = + defaultValue && Array.isArray(defaultValue) + ? defaultValue.map((nodebalancer) => String(nodebalancer)) + : []; + const nodebalancers = getNodebalancersList.filter((nodebalancerObj) => + defaultNodebalancers.includes(nodebalancerObj.id) + ); + + handleNodebalancersSelection(nodebalancers); + setSelectedNodebalancers(nodebalancers); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getNodebalancersList]); + + return ( + option.label === value.label} + label={label || 'NodeBalancers'} + limitTags={1} + loading={isLoading} + multiple + noMarginTop + onChange={(_e, nodebalancerSelections) => { + setSelectedNodebalancers(nodebalancerSelections); + + if (!isAutocompleteOpen.current) { + handleNodebalancersSelection( + nodebalancerSelections, + savePreferences + ); + } + }} + onClose={() => { + isAutocompleteOpen.current = false; + handleNodebalancersSelection( + selectedNodebalancers ?? [], + savePreferences + ); + }} + onOpen={() => { + isAutocompleteOpen.current = true; + }} + options={getNodebalancersList} + placeholder={ + selectedNodebalancers?.length + ? '' + : placeholder || 'Select NodeBalancers' + } + renderOption={(props, option) => { + const { key, ...rest } = props; + const isNodebalancerSelected = selectedNodebalancers?.some( + (item) => item.id === option.id + ); + + const isSelectAllORDeslectAllOption = + option.label === 'Select All ' || option.label === 'Deselect All '; + + const ListItem = isSelectAllORDeslectAllOption + ? StyledListItem + : 'li'; + + return ( + + <> + {option.label} + + + + ); + }} + textFieldProps={{ + ...CLOUD_PULSE_TEXT_FIELD_PROPS, + optional: isOptional, + }} + value={selectedNodebalancers ?? []} + /> + ); + }, + compareProps +); + +function compareProps( + prevProps: CloudPulseFirewallNodebalancersSelectProps, + nextProps: CloudPulseFirewallNodebalancersSelectProps +): boolean { + if (prevProps.selectedDashboard.id !== nextProps.selectedDashboard.id) { + return false; + } + if (!deepEqual(prevProps.xFilter, nextProps.xFilter)) { + return false; + } + + // Ignore function props in comparison + return true; +} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index a889102a251..49f7f6ebbd5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -15,7 +15,7 @@ import { } from '../Utils/constants'; import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import { getResourcesFilterConfig } from '../Utils/utils'; +import { getAssociatedEntityType } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { Item } from '../Alerts/constants'; @@ -84,8 +84,7 @@ export const CloudPulseRegionSelect = React.memo( const [selectedRegion, setSelectedRegion] = React.useState(); // Get the associated entity type for the dashboard - const associatedEntityType = - getResourcesFilterConfig(dashboardId)?.associatedEntityType; + const associatedEntityType = getAssociatedEntityType(dashboardId); const { values: linodeRegions, isLoading: isLinodeRegionIdLoading, From 356fc4ed2ca289494b7e79c88021cb1e24fa038c Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:27:13 +0530 Subject: [PATCH 34/39] upcoming: [DI-27663]: Resetting stale dimension filter values from the from (#13018) * upcoming: [DI-27663] - Hook to remove invalidate dimension filter values from form * renaming the hook * removed unnecessary comment * add changesets * upcmong: [DI-27663] - Added unit tests to the cleanup hook * fix tense in changeset --- ...r-13018-upcoming-features-1761563954707.md | 5 + ...rewallDimensionFilterAutocomplete.test.tsx | 113 ++++++- .../FirewallDimensionFilterAutocomplete.tsx | 10 + ...torageDimensionFilterAutocomplete.test.tsx | 47 ++- ...jectStorageDimensionFilterAutocomplete.tsx | 9 + .../useCleanupStaleValues.test.ts | 314 ++++++++++++++++++ .../useCleanupStaleValues.ts | 54 +++ 7 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.test.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts diff --git a/packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md b/packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md new file mode 100644 index 00000000000..4d7b09ea7a0 --- /dev/null +++ b/packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP-Alerting: Add hook to cleanup stale value from Alerting form ([#13018](https://github.com/linode/manager/pull/13018)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx index 58d14782764..7a5dfee8293 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { vi } from 'vitest'; @@ -242,4 +242,115 @@ describe('', () => { await screen.findByRole('option', { name: 'us-east' }) ).toBeVisible(); }); + it('cleans up invalid single value (string)', async () => { + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + // Simulate update to trigger effect + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(null); + }); + }); + + it('cleans up invalid multi value (comma-separated string)', async () => { + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith('1,2'); + }); + }); + + it('cleans up all invalid multi values (comma-separated string)', async () => { + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(''); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx index fc7a140c85e..5dd6f3f1f2d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx @@ -2,6 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; import React from 'react'; +import { useCleanupStaleValues } from './useCleanupStaleValues'; import { useFirewallFetchOptions } from './useFirewallFetchOptions'; import { handleValueChange, resolveSelectedValues } from './utils'; @@ -39,6 +40,15 @@ export const FirewallDimensionFilterAutocomplete = ( type, scope, }); + + useCleanupStaleValues({ + options: values, + fieldValue, + multiple, + onChange: fieldOnChange, + isLoading, + }); + return ( ', () => { 'us-east-1.linodeobjects.com,us-west-1.linodeobjects.com' ); }); + it('field cleanup removes invalid values', async () => { + queryMocks.useObjectStorageFetchOptions.mockReturnValue({ + values: [ + { + label: 'us-east-1.linodeobjects.com', + value: 'us-east-1.linodeobjects.com', + }, + ], + isLoading: false, + isError: false, + }); + + const fieldOnChange = vi.fn(); + + const { rerender } = renderWithTheme( + + ); + + queryMocks.useObjectStorageFetchOptions.mockReturnValue({ + values: [ + { + label: 'us-west-1.linodeobjects.com', + value: 'us-west-1.linodeobjects.com', + }, + ], + isLoading: false, + isError: false, + }); + // Trigger the effect by rerendering with the same props + rerender( + + ); + // fieldOnChange should be called to clean up the invalid value + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(null); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx index 777f8eb35bb..106a62104ff 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx @@ -2,6 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; import React from 'react'; +import { useCleanupStaleValues } from './useCleanupStaleValues'; import { useObjectStorageFetchOptions } from './useObjectStorageFetchOptions'; import { handleValueChange, resolveSelectedValues } from './utils'; @@ -41,6 +42,14 @@ export const ObjectStorageDimensionFilterAutocomplete = ( serviceType, }); + useCleanupStaleValues({ + options: values, + fieldValue, + multiple, + onChange: fieldOnChange, + isLoading, + }); + return ( [] = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { label: 'Option 3', value: 'opt3' }, +]; + +describe('useCleanupStaleValues - loading state', () => { + it('should not call onChange when isLoading is true', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: STALE_VALUE, + isLoading: true, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('useCleanupStaleValues - null or empty values', () => { + it('should not call onChange when fieldValue is null', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: null, + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not call onChange when fieldValue is an empty array', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: [], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not call onChange when fieldValue is an empty string', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: '', + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('useCleanupStaleValues - single selection mode', () => { + it('should not call onChange when value is valid', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: 'opt1', + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should call onChange with null when value is not in options', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: STALE_VALUE, + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should handle array fieldValue by using the first element', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: [STALE_VALUE], + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('should not call onChange when array fieldValue contains valid value', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt2'], + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should clear value when options no longer include it', () => { + const onChange = vi.fn(); + + const { rerender } = renderHook( + ({ options }) => + useCleanupStaleValues({ + onChange, + options, + fieldValue: 'opt2', + isLoading: false, + multiple: false, + }), + { initialProps: { options: mockOptions } } + ); + + expect(onChange).not.toHaveBeenCalled(); + + const newOptions: Item[] = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 3', value: 'opt3' }, + ]; + + rerender({ options: newOptions }); + + expect(onChange).toHaveBeenCalledWith(null); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('useCleanupStaleValues - multiple selection mode', () => { + it('should not call onChange when all values are valid', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt1', 'opt2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should filter out stale values from array', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt1', STALE_VALUE, 'opt2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).toHaveBeenCalledWith(['opt1', 'opt2']); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should handle comma-separated string values', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: `opt1,${STALE_VALUE},opt2`, + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).toHaveBeenCalledWith('opt1,opt2'); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should call onChange when all values are stale', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['stale1', 'stale2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).toHaveBeenCalledWith([]); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should cleanup values when options are updated', () => { + const onChange = vi.fn(); + + const { rerender } = renderHook( + ({ options }) => + useCleanupStaleValues({ + onChange, + options, + fieldValue: ['opt1', 'opt2'], + isLoading: false, + multiple: true, + }), + { initialProps: { options: mockOptions } } + ); + + expect(onChange).not.toHaveBeenCalled(); + + const newOptions: Item[] = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 3', value: 'opt3' }, + ]; + + rerender({ options: newOptions }); + + expect(onChange).toHaveBeenCalledWith(['opt1']); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('useCleanupStaleValues - edge cases', () => { + it('should handle empty options array', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: [], + fieldValue: 'opt1', + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('should not call onChange repeatedly on subsequent renders', () => { + const onChange = vi.fn(); + + const { rerender } = renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt1', 'opt2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + + rerender(); + rerender(); + rerender(); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts new file mode 100644 index 00000000000..ff10bb4c066 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; + +import type { Item } from '../../../constants'; + +/** + * Cleans up stale form values that are no longer in the options list. + */ +export const useCleanupStaleValues = ({ + options, + fieldValue, + multiple, + onChange, + isLoading, +}: { + fieldValue: null | string | string[]; + isLoading?: boolean; + multiple?: boolean; + onChange: (value: null | string | string[]) => void; + options: Item[]; +}) => { + useEffect(() => { + if (isLoading) { + return; + } + + if (!fieldValue || (Array.isArray(fieldValue) && fieldValue.length === 0)) { + return; + } + + const validValues = options.map((o) => o.value); + + if (multiple) { + const isArray = Array.isArray(fieldValue); + const selected = isArray + ? fieldValue + : fieldValue.split(',').filter((v) => v.trim() !== ''); + + const filtered = selected.filter((v) => validValues.includes(v)); + + if (filtered.length !== selected.length) { + onChange(isArray ? filtered : filtered.join(',')); + } + } else { + const value = Array.isArray(fieldValue) ? fieldValue[0] : fieldValue; + + if (value) { + const isStillValid = validValues.includes(value); + if (!isStillValid) { + onChange(null); + } + } + } + }, [options, fieldValue, multiple, onChange, isLoading]); +}; From 0de884266ea6c003ef55c119f8d295428a9c0470 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:39:16 -0400 Subject: [PATCH 35/39] =?UTF-8?q?fix:=20[M3-10691]=20-=20Upcoming=20mainte?= =?UTF-8?q?nance=20=E2=80=9CWhen=E2=80=9D=20shows=20time=20until=20mainten?= =?UTF-8?q?ance;=20derive=20start=20when=20absent=20(#13020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * change: [M3-10691] - Upcoming maintenance “When” shows time until maintenance; derive start when absent * Add changeset * Change changeset * Fix double display of banners for a single linode * Mock maintenance policies for MSW to ensure proper time * Update broken cypress tests due to banner display changes * revert PlatformMaintenanceBanner.tsx changes * Update unit test --------- Co-authored-by: Jaalah Ramos --- .../pr-13020-fixed-1761580968161.md | 5 + .../LinodePlatformMaintenanceBanner.tsx | 6 +- .../Maintenance/MaintenanceTable.test.tsx | 11 +- .../Maintenance/MaintenanceTableRow.tsx | 40 +++++- .../Account/Maintenance/utilities.test.ts | 136 ++++++++++++++++++ .../features/Account/Maintenance/utilities.ts | 74 ++++++++++ .../extra/account/customMaintenance.ts | 27 +++- 7 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-13020-fixed-1761580968161.md create mode 100644 packages/manager/src/features/Account/Maintenance/utilities.test.ts diff --git a/packages/manager/.changeset/pr-13020-fixed-1761580968161.md b/packages/manager/.changeset/pr-13020-fixed-1761580968161.md new file mode 100644 index 00000000000..c9bcdc70234 --- /dev/null +++ b/packages/manager/.changeset/pr-13020-fixed-1761580968161.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Upcoming maintenance "When" shows time until start using start_time or policy‑derived start; shows "X days Y hours" when ≥ 1 day ([#13020](https://github.com/linode/manager/pull/13020)) diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx index 9963f22a970..e730df5cd3e 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx @@ -52,7 +52,11 @@ export const LinodePlatformMaintenanceBanner = (props: { return ( <> - + diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx index 643e0d43ff4..2d1e112c326 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx @@ -8,7 +8,6 @@ import * as React from 'react'; import { accountMaintenanceFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; import { mockMatchMedia, @@ -18,6 +17,7 @@ import { import { MaintenanceTable } from './MaintenanceTable'; import { MaintenanceTableRow } from './MaintenanceTableRow'; +import { getUpcomingRelativeLabel } from './utilities'; beforeAll(() => mockMatchMedia()); @@ -45,11 +45,10 @@ describe('Maintenance Table Row', () => { ); const { getByText } = within(screen.getByTestId('relative-date')); - if (maintenance.when) { - expect( - getByText(parseAPIDate(maintenance.when).toRelative()!) - ).toBeInTheDocument(); - } + // The upcoming relative label prefers the actual or policy-derived start time; + // falls back to the notice time when start cannot be determined. + const expected = getUpcomingRelativeLabel(maintenance); + expect(getByText(expected)).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 6a322be998e..d4fc3505a55 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -1,4 +1,7 @@ -import { useProfile } from '@linode/queries'; +import { + useAccountMaintenancePoliciesQuery, + useProfile, +} from '@linode/queries'; import { Stack, Tooltip } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize, getFormattedStatus, truncate } from '@linode/utilities'; @@ -19,7 +22,11 @@ import { useInProgressEvents } from 'src/queries/events/events'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; -import { getMaintenanceDateField } from './utilities'; +import { + deriveMaintenanceStartISO, + getMaintenanceDateField, + getUpcomingRelativeLabel, +} from './utilities'; import type { MaintenanceTableType } from './MaintenanceTable'; import type { AccountMaintenance } from '@linode/api-v4/lib/account/types'; @@ -77,6 +84,23 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { const dateField = getMaintenanceDateField(tableType); const dateValue = props.maintenance[dateField]; + // Fetch policies to derive a start time when the API doesn't provide one + const { data: policies } = useAccountMaintenancePoliciesQuery(); + + // Precompute for potential use; currently used via getUpcomingRelativeLabel + React.useMemo( + () => deriveMaintenanceStartISO(props.maintenance, policies), + [policies, props.maintenance] + ); + + const upcomingRelativeLabel = React.useMemo( + () => + tableType === 'upcoming' + ? getUpcomingRelativeLabel(props.maintenance, policies) + : undefined, + [policies, props.maintenance, tableType] + ); + return ( @@ -114,7 +138,11 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { {(tableType === 'upcoming' || tableType === 'completed') && ( - {when ? parseAPIDate(when).toRelative() : '—'} + {tableType === 'upcoming' + ? upcomingRelativeLabel + : when + ? parseAPIDate(when).toRelative() + : '—'} )} @@ -137,7 +165,11 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { - {when ? parseAPIDate(when).toRelative() : '—'} + {tableType === 'upcoming' + ? upcomingRelativeLabel + : when + ? parseAPIDate(when).toRelative() + : '—'} diff --git a/packages/manager/src/features/Account/Maintenance/utilities.test.ts b/packages/manager/src/features/Account/Maintenance/utilities.test.ts new file mode 100644 index 00000000000..09e022ae4f8 --- /dev/null +++ b/packages/manager/src/features/Account/Maintenance/utilities.test.ts @@ -0,0 +1,136 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { + deriveMaintenanceStartISO, + getUpcomingRelativeLabel, +} from './utilities'; + +import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; + +// Freeze time to a stable reference so relative labels are deterministic +const NOW_ISO = '2025-10-27T12:00:00.000Z'; + +describe('Account Maintenance utilities', () => { + const policies: MaintenancePolicy[] = [ + { + description: 'Migrate', + is_default: true, + label: 'Migrate', + notification_period_sec: 3 * 60 * 60, // 3 hours + slug: 'linode/migrate', + type: 'linode_migrate', + }, + { + description: 'Power Off / On', + is_default: false, + label: 'Power Off / Power On', + notification_period_sec: 72 * 60 * 60, // 72 hours + slug: 'linode/power_off_on', + type: 'linode_power_off_on', + }, + ]; + + const baseMaintenance: Omit & { when: string } = { + complete_time: null, + description: 'scheduled', + entity: { id: 1, label: 'linode-1', type: 'linode', url: '' }, + maintenance_policy_set: 'linode/migrate', + not_before: null, + reason: 'Test', + source: 'platform', + start_time: null, + status: 'scheduled', + type: 'migrate', + when: '2025-10-27T09:00:00.000Z', + }; + + // Mock Date.now used by Luxon under the hood for relative calculations + let realDateNow: () => number; + beforeAll(() => { + realDateNow = Date.now; + vi.spyOn(Date, 'now').mockImplementation(() => new Date(NOW_ISO).getTime()); + }); + afterAll(() => { + Date.now = realDateNow; + }); + + describe('deriveMaintenanceStartISO', () => { + it('returns provided start_time when available', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: '2025-10-27T12:00:00.000Z', + }; + expect(deriveMaintenanceStartISO(m, policies)).toBe( + '2025-10-27T12:00:00.000Z' + ); + }); + + it('derives start_time from when + policy seconds when missing', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-10-27T09:00:00.000Z', // +3h -> 12:00Z + }; + expect(deriveMaintenanceStartISO(m, policies)).toBe( + '2025-10-27T12:00:00.000Z' + ); + }); + + it('returns undefined when policy cannot be found', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + // Use an intentionally unknown slug to exercise the no-policy fallback path. + // Even though the API default is typically 'linode/migrate', the client may + // not have policies loaded yet or could encounter a fetch error; this ensures + // we verify the graceful fallback behavior. + maintenance_policy_set: 'unknown/policy' as any, + }; + expect(deriveMaintenanceStartISO(m, policies)).toBeUndefined(); + }); + }); + + describe('getUpcomingRelativeLabel', () => { + it('falls back to notice-relative when start cannot be determined', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + // Force the no-policy path to validate the notice-relative fallback + maintenance_policy_set: 'unknown/policy' as any, + when: '2025-10-27T10:00:00.000Z', + }; + // NOW=12:00Z, when=10:00Z => "2 hours ago" + expect(getUpcomingRelativeLabel(m, policies)).toContain('hour'); + }); + + it('uses derived start to express time until maintenance (hours when <1 day)', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + // when 09:00Z + 3h = 12:00Z; NOW=12:00Z -> label likely "a few seconds ago" or similar + when: '2025-10-27T09:00:00.000Z', + }; + // Allow any non-empty string; exact phrasing depends on Luxon locale + expect(getUpcomingRelativeLabel(m, policies)).toBeTypeOf('string'); + }); + + it('shows days+hours when >= 1 day away (avoids day-only rounding)', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + maintenance_policy_set: 'linode/power_off_on', // 72h + when: '2025-10-25T20:00:00.000Z', // +72h => 2025-10-28T20:00Z; from NOW (27 12:00Z) => 1 day 8 hours + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 1 day 8 hours'); + }); + + it('formats days and hours precisely when start_time is known', () => { + // From NOW (2025-10-27T12:00Z) to 2025-10-30T04:00Z is 2 days 16 hours + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: '2025-10-30T04:00:00.000Z', + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 2 days 16 hours'); + }); + }); +}); diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index e382dbbe3f3..95c9bb8ca33 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -1,4 +1,10 @@ +import { pluralize } from '@linode/utilities'; +import { DateTime } from 'luxon'; + +import { parseAPIDate } from 'src/utilities/date'; + import type { MaintenanceTableType } from './MaintenanceTable'; +import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; export const COMPLETED_MAINTENANCE_FILTER = Object.freeze({ status: { '+or': ['completed', 'canceled'] }, @@ -48,3 +54,71 @@ export const getMaintenanceDateField = ( export const getMaintenanceDateLabel = (type: MaintenanceTableType): string => { return maintenanceDateColumnMap[type][1]; }; + +/** + * Derive the maintenance start when API `start_time` is absent by adding the + * policy notification window to the `when` (notice publish time). + */ +export const deriveMaintenanceStartISO = ( + maintenance: AccountMaintenance, + policies?: MaintenancePolicy[] +): string | undefined => { + if (maintenance.start_time) { + return maintenance.start_time; + } + const notificationSecs = policies?.find( + (p) => p.slug === maintenance.maintenance_policy_set + )?.notification_period_sec; + if (maintenance.when && notificationSecs) { + try { + return parseAPIDate(maintenance.when) + .plus({ seconds: notificationSecs }) + .toISO(); + } catch { + return undefined; + } + } + return undefined; +}; + +/** + * Build a user-friendly relative label for the Upcoming table. + * - Prefers the actual/derived start time to express time until maintenance + * - Falls back to the notice relative time when start cannot be determined + * - Avoids day-only rounding by showing days + hours when >= 1 day + */ +export const getUpcomingRelativeLabel = ( + maintenance: AccountMaintenance, + policies?: MaintenancePolicy[] +): string => { + const startISO = deriveMaintenanceStartISO(maintenance, policies); + + // Fallback: when start cannot be determined, show the notice time relative to now + if (!startISO) { + return maintenance.when + ? (parseAPIDate(maintenance.when).toRelative() ?? '—') + : '—'; + } + + // Prefer the actual or policy-derived start time to express "time until maintenance" + const startDT = parseAPIDate(startISO); + const now = DateTime.local(); + if (startDT <= now) { + return startDT.toRelative() ?? '—'; + } + + // Avoid day-only rounding near boundaries by including hours alongside days + const diff = startDT.diff(now, ['days', 'hours']).toObject(); + let days = Math.floor(diff.days ?? 0); + let hours = Math.round(diff.hours ?? 0); + if (hours === 24) { + days += 1; + hours = 0; + } + if (days >= 1) { + const dayPart = pluralize('day', 'days', days); + const hourPart = hours ? ` ${pluralize('hour', 'hours', hours)}` : ''; + return `in ${dayPart}${hourPart}`; + } + return startDT.toRelative({ unit: 'hours', round: true }) ?? '—'; +}; diff --git a/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts b/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts index 571f7a98b24..b504f641da3 100644 --- a/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts +++ b/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw'; import { makeResourcePage } from 'src/mocks/serverHandlers'; -import type { AccountMaintenance } from '@linode/api-v4'; +import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; import type { MockPresetExtra } from 'src/mocks/types'; let customMaintenanceData: AccountMaintenance[] | null = null; @@ -13,6 +13,7 @@ export const setCustomMaintenanceData = (data: AccountMaintenance[] | null) => { const mockCustomMaintenance = () => { return [ + // Account maintenance items (supports filtering and pagination similar to prod) http.get('*/account/maintenance', ({ request }) => { const url = new URL(request.url); @@ -59,6 +60,30 @@ const mockCustomMaintenance = () => { return HttpResponse.json(makeResourcePage(accountMaintenance)); }), + + // Maintenance policies used to derive start times from notice `when` + http.get('*/maintenance/policies', () => { + const policies: MaintenancePolicy[] = [ + { + description: 'Migrate', + is_default: true, + label: 'Migrate', + notification_period_sec: 3 * 60 * 60, // 3 hours + slug: 'linode/migrate', + type: 'linode_migrate', + }, + { + description: 'Power Off / Power On', + is_default: false, + label: 'Power Off / Power On', + notification_period_sec: 72 * 60 * 60, // 72 hours + slug: 'linode/power_off_on', + type: 'linode_power_off_on', + }, + ]; + + return HttpResponse.json(makeResourcePage(policies)); + }), ]; }; From 4f4e91d44868346aa94d4bc5e9748ff6f3840ff3 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:55:46 +0100 Subject: [PATCH 36/39] fix: [UIE-9484] - Enhance `enabled` checks for queries ran within `useQueryWithPermissions` (#13039) --- packages/manager/.changeset/pr-13039-fixed-1761836949620.md | 5 +++++ .../Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx | 2 +- .../src/features/Images/ImagesCreate/CreateImageTab.tsx | 2 +- .../src/features/Images/ImagesLanding/RebuildImageDrawer.tsx | 2 +- .../src/features/NodeBalancers/NodeBalancerSelect.tsx | 2 +- .../features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx | 2 +- 6 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-13039-fixed-1761836949620.md diff --git a/packages/manager/.changeset/pr-13039-fixed-1761836949620.md b/packages/manager/.changeset/pr-13039-fixed-1761836949620.md new file mode 100644 index 00000000000..92402b4fe46 --- /dev/null +++ b/packages/manager/.changeset/pr-13039-fixed-1761836949620.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Enhance `enabled` checks for queries ran within `useQueryWithPermissions` ([#13039](https://github.com/linode/manager/pull/13039)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index e56eff9d7cf..ae6127c5be5 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -61,7 +61,7 @@ export const AddLinodeDrawer = (props: Props) => { const { data: availableLinodes, isLoading: availableLinodesLoading } = useQueryWithPermissions( - useAllLinodesQuery({}, {}), + useAllLinodesQuery({}, {}, open), 'linode', ['update_linode'], open diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 2c29e7ee8dd..9a48643ff90 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -109,7 +109,7 @@ export const CreateImageTab = () => { ); const { data: linodes, isLoading } = useQueryWithPermissions( - useAllLinodesQuery(), + useAllLinodesQuery({}, {}, Boolean(imagePermissions.create_image)), 'linode', ['view_linode', 'update_linode'], Boolean(imagePermissions.create_image) diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index 6c8480a7dfa..f4366745cda 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -24,7 +24,7 @@ export const RebuildImageDrawer = (props: Props) => { const navigate = useNavigate(); const { data: linodes, isLoading } = useQueryWithPermissions( - useAllLinodesQuery(), + useAllLinodesQuery({}, {}, open), 'linode', ['rebuild_linode', 'view_linode'], open diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index a91724ebde0..adac3c1aacb 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -102,7 +102,7 @@ export const NodeBalancerSelect = ( error: availableNodebalancersError, isLoading: availableNodebalancersLoading, } = useQueryWithPermissions( - useAllNodeBalancersQuery(), + useAllNodeBalancersQuery(Boolean(optionsFilter)), 'nodebalancer', ['update_nodebalancer'], Boolean(optionsFilter) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 7230f56b8e0..7a39a12c019 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -125,7 +125,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( // TODO: change to 'delete_linode_config_profile_interface' once it's available const { data: filteredLinodes, isLoading: isLoadingFilteredLinodes } = useQueryWithPermissions( - useAllLinodesQuery(), + useAllLinodesQuery({}, {}, open), 'linode', ['delete_linode'], open From a44326ce82776784c4dbd5e7231f129e0095231d Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Thu, 30 Oct 2025 13:22:16 -0400 Subject: [PATCH 37/39] Cloud version 1.154.0, API v4 version 0.152.0, Validation version 0.78.0, UI version 0.23.0 --- .../.changeset/pr-12984-upcoming-features-1761308732683.md | 5 ----- .../.changeset/pr-12985-upcoming-features-1761307537636.md | 5 ----- .../.changeset/pr-13006-upcoming-features-1761296381753.md | 5 ----- .../.changeset/pr-12792-tech-stories-1756824978680.md | 5 ----- .../.changeset/pr-12824-tech-stories-1760646228493.md | 5 ----- packages/manager/.changeset/pr-12876-tests-1757711939971.md | 5 ----- packages/manager/.changeset/pr-12884-tests-1758049882650.md | 5 ----- packages/manager/.changeset/pr-12936-tests-1759312166894.md | 5 ----- .../.changeset/pr-12970-upcoming-features-1760351502737.md | 5 ----- packages/manager/.changeset/pr-12982-added-1760358578837.md | 5 ----- .../.changeset/pr-12990-upcoming-features-1761211528417.md | 5 ----- packages/manager/.changeset/pr-12991-fixed-1760562047563.md | 5 ----- .../.changeset/pr-12992-upcoming-features-1760600896958.md | 5 ----- .../.changeset/pr-12996-upcoming-features-1760962341317.md | 5 ----- packages/manager/.changeset/pr-12997-fixed-1760991111727.md | 5 ----- .../.changeset/pr-12999-upcoming-features-1761042187222.md | 5 ----- packages/manager/.changeset/pr-13003-fixed-1761144241306.md | 5 ----- .../.changeset/pr-13006-upcoming-features-1761296439986.md | 5 ----- .../manager/.changeset/pr-13007-changed-1761211855914.md | 5 ----- .../.changeset/pr-13011-upcoming-features-1761229143134.md | 5 ----- packages/manager/.changeset/pr-13012-fixed-1761654121842.md | 5 ----- packages/manager/.changeset/pr-13013-fixed-1761296675184.md | 5 ----- .../.changeset/pr-13014-upcoming-features-1761313517879.md | 5 ----- .../manager/.changeset/pr-13015-changed-1761680512143.md | 5 ----- .../.changeset/pr-13018-upcoming-features-1761563954707.md | 5 ----- packages/manager/.changeset/pr-13020-fixed-1761580968161.md | 5 ----- packages/manager/.changeset/pr-13024-fixed-1761594892256.md | 5 ----- packages/manager/.changeset/pr-13026-fixed-1761637569095.md | 5 ----- .../.changeset/pr-13028-upcoming-features-1761667428736.md | 5 ----- .../.changeset/pr-13029-upcoming-features-1761669638634.md | 5 ----- packages/manager/.changeset/pr-13030-fixed-1761674529303.md | 5 ----- .../manager/.changeset/pr-13035-changed-1761764242108.md | 5 ----- packages/manager/.changeset/pr-13039-fixed-1761836949620.md | 5 ----- packages/ui/.changeset/pr-12988-fixed-1760538879992.md | 5 ----- .../.changeset/pr-12984-upcoming-features-1761308818735.md | 5 ----- .../.changeset/pr-12985-upcoming-features-1761308567524.md | 5 ----- 36 files changed, 180 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md delete mode 100644 packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md delete mode 100644 packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md delete mode 100644 packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md delete mode 100644 packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md delete mode 100644 packages/manager/.changeset/pr-12876-tests-1757711939971.md delete mode 100644 packages/manager/.changeset/pr-12884-tests-1758049882650.md delete mode 100644 packages/manager/.changeset/pr-12936-tests-1759312166894.md delete mode 100644 packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md delete mode 100644 packages/manager/.changeset/pr-12982-added-1760358578837.md delete mode 100644 packages/manager/.changeset/pr-12990-upcoming-features-1761211528417.md delete mode 100644 packages/manager/.changeset/pr-12991-fixed-1760562047563.md delete mode 100644 packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md delete mode 100644 packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md delete mode 100644 packages/manager/.changeset/pr-12997-fixed-1760991111727.md delete mode 100644 packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md delete mode 100644 packages/manager/.changeset/pr-13003-fixed-1761144241306.md delete mode 100644 packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md delete mode 100644 packages/manager/.changeset/pr-13007-changed-1761211855914.md delete mode 100644 packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md delete mode 100644 packages/manager/.changeset/pr-13012-fixed-1761654121842.md delete mode 100644 packages/manager/.changeset/pr-13013-fixed-1761296675184.md delete mode 100644 packages/manager/.changeset/pr-13014-upcoming-features-1761313517879.md delete mode 100644 packages/manager/.changeset/pr-13015-changed-1761680512143.md delete mode 100644 packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md delete mode 100644 packages/manager/.changeset/pr-13020-fixed-1761580968161.md delete mode 100644 packages/manager/.changeset/pr-13024-fixed-1761594892256.md delete mode 100644 packages/manager/.changeset/pr-13026-fixed-1761637569095.md delete mode 100644 packages/manager/.changeset/pr-13028-upcoming-features-1761667428736.md delete mode 100644 packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md delete mode 100644 packages/manager/.changeset/pr-13030-fixed-1761674529303.md delete mode 100644 packages/manager/.changeset/pr-13035-changed-1761764242108.md delete mode 100644 packages/manager/.changeset/pr-13039-fixed-1761836949620.md delete mode 100644 packages/ui/.changeset/pr-12988-fixed-1760538879992.md delete mode 100644 packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md delete mode 100644 packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md diff --git a/packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md b/packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md deleted file mode 100644 index 588b2a0b406..00000000000 --- a/packages/api-v4/.changeset/pr-12984-upcoming-features-1761308732683.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add endpoints for `/v4/images/sharegroups/members` and `/v4/images/sharegroups/tokens` ([#12984](https://github.com/linode/manager/pull/12984)) diff --git a/packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md b/packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md deleted file mode 100644 index 6295fd8efbe..00000000000 --- a/packages/api-v4/.changeset/pr-12985-upcoming-features-1761307537636.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add endpoints for `/v4/images/sharegroups` and `/v4/images/sharegroups/images` ([#12985](https://github.com/linode/manager/pull/12985)) diff --git a/packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md b/packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md deleted file mode 100644 index 8f931054913..00000000000 --- a/packages/api-v4/.changeset/pr-13006-upcoming-features-1761296381753.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add new `filters` prop in AclpWidget type and update `Filters` type to use `DimensionFilterOperatorType` for operator ([#13006](https://github.com/linode/manager/pull/13006)) diff --git a/packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md b/packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md deleted file mode 100644 index c4240611d7d..00000000000 --- a/packages/manager/.changeset/pr-12792-tech-stories-1756824978680.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Update Vite to v7 ([#12792](https://github.com/linode/manager/pull/12792)) diff --git a/packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md b/packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md deleted file mode 100644 index 07b26be9338..00000000000 --- a/packages/manager/.changeset/pr-12824-tech-stories-1760646228493.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Upgrade Cypress to v15.4.0 ([#12824](https://github.com/linode/manager/pull/12824)) diff --git a/packages/manager/.changeset/pr-12876-tests-1757711939971.md b/packages/manager/.changeset/pr-12876-tests-1757711939971.md deleted file mode 100644 index 0a1a6be5069..00000000000 --- a/packages/manager/.changeset/pr-12876-tests-1757711939971.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Linode Interface related tests: deleting an interface, editing interfaces, and updating interface settings ([#12876](https://github.com/linode/manager/pull/12876)) diff --git a/packages/manager/.changeset/pr-12884-tests-1758049882650.md b/packages/manager/.changeset/pr-12884-tests-1758049882650.md deleted file mode 100644 index ef1c6bddfb9..00000000000 --- a/packages/manager/.changeset/pr-12884-tests-1758049882650.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix "lke-update.spec.ts" LKE-E node pool drawer test that's broken in DevCloud ([#12884](https://github.com/linode/manager/pull/12884)) diff --git a/packages/manager/.changeset/pr-12936-tests-1759312166894.md b/packages/manager/.changeset/pr-12936-tests-1759312166894.md deleted file mode 100644 index f49e7789f8a..00000000000 --- a/packages/manager/.changeset/pr-12936-tests-1759312166894.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Logs Destination Landing, Create and Edit e2e tests ([#12936](https://github.com/linode/manager/pull/12936)) diff --git a/packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md b/packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md deleted file mode 100644 index b9edd11c74a..00000000000 --- a/packages/manager/.changeset/pr-12970-upcoming-features-1760351502737.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -IAM: Account Delegations Drawer ([#12970](https://github.com/linode/manager/pull/12970)) diff --git a/packages/manager/.changeset/pr-12982-added-1760358578837.md b/packages/manager/.changeset/pr-12982-added-1760358578837.md deleted file mode 100644 index ccb8fb15a07..00000000000 --- a/packages/manager/.changeset/pr-12982-added-1760358578837.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -IAM Parent/Child: hide User details tab for delegate user and add a badge ([#12982](https://github.com/linode/manager/pull/12982)) diff --git a/packages/manager/.changeset/pr-12990-upcoming-features-1761211528417.md b/packages/manager/.changeset/pr-12990-upcoming-features-1761211528417.md deleted file mode 100644 index 23741f424ff..00000000000 --- a/packages/manager/.changeset/pr-12990-upcoming-features-1761211528417.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -IAM: Default Roles Table ([#12990](https://github.com/linode/manager/pull/12990)) diff --git a/packages/manager/.changeset/pr-12991-fixed-1760562047563.md b/packages/manager/.changeset/pr-12991-fixed-1760562047563.md deleted file mode 100644 index a307ee47967..00000000000 --- a/packages/manager/.changeset/pr-12991-fixed-1760562047563.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -NodeBalancer Configuration form unresponsiveness for larger VPC deployments ([#12991](https://github.com/linode/manager/pull/12991)) diff --git a/packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md b/packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md deleted file mode 100644 index 2c223a26cb9..00000000000 --- a/packages/manager/.changeset/pr-12992-upcoming-features-1760600896958.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add support for `privateImageSharing` feature flag for Private Image Sharing feature ([#12992](https://github.com/linode/manager/pull/12992)) diff --git a/packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md b/packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md deleted file mode 100644 index ec8379969f2..00000000000 --- a/packages/manager/.changeset/pr-12996-upcoming-features-1760962341317.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Logs Delivery Destinations/Stream Delete confirmation modal error state reset fix ([#12996](https://github.com/linode/manager/pull/12996)) diff --git a/packages/manager/.changeset/pr-12997-fixed-1760991111727.md b/packages/manager/.changeset/pr-12997-fixed-1760991111727.md deleted file mode 100644 index 8578e885a8e..00000000000 --- a/packages/manager/.changeset/pr-12997-fixed-1760991111727.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM Roles Table styles and responsive enhancements ([#12997](https://github.com/linode/manager/pull/12997)) diff --git a/packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md b/packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md deleted file mode 100644 index 2fb1a1c4bb2..00000000000 --- a/packages/manager/.changeset/pr-12999-upcoming-features-1761042187222.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Stream form bug fixes ([#12999](https://github.com/linode/manager/pull/12999)) diff --git a/packages/manager/.changeset/pr-13003-fixed-1761144241306.md b/packages/manager/.changeset/pr-13003-fixed-1761144241306.md deleted file mode 100644 index 564c306a983..00000000000 --- a/packages/manager/.changeset/pr-13003-fixed-1761144241306.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM Account Delegation Tables sorting & filtering ([#13003](https://github.com/linode/manager/pull/13003)) diff --git a/packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md b/packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md deleted file mode 100644 index 07e354eaf8b..00000000000 --- a/packages/manager/.changeset/pr-13006-upcoming-features-1761296439986.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add type, utility and mock setup for supporting widget level dimension filters ([#13006](https://github.com/linode/manager/pull/13006)) diff --git a/packages/manager/.changeset/pr-13007-changed-1761211855914.md b/packages/manager/.changeset/pr-13007-changed-1761211855914.md deleted file mode 100644 index 81bf8e6c59f..00000000000 --- a/packages/manager/.changeset/pr-13007-changed-1761211855914.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -IAM Parent/Child: redirect route /delegations for non-parent users ([#13007](https://github.com/linode/manager/pull/13007)) diff --git a/packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md b/packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md deleted file mode 100644 index 4e81d563365..00000000000 --- a/packages/manager/.changeset/pr-13011-upcoming-features-1761229143134.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -IAM Delegation: Parent Account UI fix ([#13011](https://github.com/linode/manager/pull/13011)) diff --git a/packages/manager/.changeset/pr-13012-fixed-1761654121842.md b/packages/manager/.changeset/pr-13012-fixed-1761654121842.md deleted file mode 100644 index 599993df3d0..00000000000 --- a/packages/manager/.changeset/pr-13012-fixed-1761654121842.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM - Ensure useEntitiesPermissions does not run for admin users ([#13012](https://github.com/linode/manager/pull/13012)) diff --git a/packages/manager/.changeset/pr-13013-fixed-1761296675184.md b/packages/manager/.changeset/pr-13013-fixed-1761296675184.md deleted file mode 100644 index 7bbef8c3be3..00000000000 --- a/packages/manager/.changeset/pr-13013-fixed-1761296675184.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM Parent/Child: fix spacing and add notification ([#13013](https://github.com/linode/manager/pull/13013)) diff --git a/packages/manager/.changeset/pr-13014-upcoming-features-1761313517879.md b/packages/manager/.changeset/pr-13014-upcoming-features-1761313517879.md deleted file mode 100644 index 13e53ee2ef3..00000000000 --- a/packages/manager/.changeset/pr-13014-upcoming-features-1761313517879.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Update `FilterConfig.ts` to make firewall a single-select filter and to filter firewalls based on dashboard ([#13014](https://github.com/linode/manager/pull/13014)) diff --git a/packages/manager/.changeset/pr-13015-changed-1761680512143.md b/packages/manager/.changeset/pr-13015-changed-1761680512143.md deleted file mode 100644 index f1ca01035f9..00000000000 --- a/packages/manager/.changeset/pr-13015-changed-1761680512143.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Prevent database queries from sending legacy filter and remove unused banner components ([#13015](https://github.com/linode/manager/pull/13015)) diff --git a/packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md b/packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md deleted file mode 100644 index 4d7b09ea7a0..00000000000 --- a/packages/manager/.changeset/pr-13018-upcoming-features-1761563954707.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -ACLP-Alerting: Add hook to cleanup stale value from Alerting form ([#13018](https://github.com/linode/manager/pull/13018)) diff --git a/packages/manager/.changeset/pr-13020-fixed-1761580968161.md b/packages/manager/.changeset/pr-13020-fixed-1761580968161.md deleted file mode 100644 index c9bcdc70234..00000000000 --- a/packages/manager/.changeset/pr-13020-fixed-1761580968161.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Upcoming maintenance "When" shows time until start using start_time or policy‑derived start; shows "X days Y hours" when ≥ 1 day ([#13020](https://github.com/linode/manager/pull/13020)) diff --git a/packages/manager/.changeset/pr-13024-fixed-1761594892256.md b/packages/manager/.changeset/pr-13024-fixed-1761594892256.md deleted file mode 100644 index 7265838a89c..00000000000 --- a/packages/manager/.changeset/pr-13024-fixed-1761594892256.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Add self-service maintenance action in LinodeMaintenanceBanner for power_off_on and include all maintenance types in dev tools preset ([#13024](https://github.com/linode/manager/pull/13024)) diff --git a/packages/manager/.changeset/pr-13026-fixed-1761637569095.md b/packages/manager/.changeset/pr-13026-fixed-1761637569095.md deleted file mode 100644 index 518d6554887..00000000000 --- a/packages/manager/.changeset/pr-13026-fixed-1761637569095.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@linode/manager': Fixed ---- - -Add new `mtc` feature flag, extend it to support valid regions for MTC Linode Migration, and replace the invalid region ID `no-east` ([#13026](https://github.com/linode/manager/pull/13026)) diff --git a/packages/manager/.changeset/pr-13028-upcoming-features-1761667428736.md b/packages/manager/.changeset/pr-13028-upcoming-features-1761667428736.md deleted file mode 100644 index b9c1aaa63af..00000000000 --- a/packages/manager/.changeset/pr-13028-upcoming-features-1761667428736.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Hide scroll bar for filters in all browsers, introduce shared prop in `styles.ts`. ([#13028](https://github.com/linode/manager/pull/13028)) diff --git a/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md b/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md deleted file mode 100644 index 444f9327d46..00000000000 --- a/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Add optional-filter component at `CloudPulseFirewallNodebalancersSelect.tsx` and integrate it with existing firewall-nodebalancer filters ([#13029](https://github.com/linode/manager/pull/13029)) diff --git a/packages/manager/.changeset/pr-13030-fixed-1761674529303.md b/packages/manager/.changeset/pr-13030-fixed-1761674529303.md deleted file mode 100644 index 19951a077c4..00000000000 --- a/packages/manager/.changeset/pr-13030-fixed-1761674529303.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM: Linodes without required permissions visible and selectable in Assign/Unassign Linodes selector ([#13030](https://github.com/linode/manager/pull/13030)) diff --git a/packages/manager/.changeset/pr-13035-changed-1761764242108.md b/packages/manager/.changeset/pr-13035-changed-1761764242108.md deleted file mode 100644 index 1112c001944..00000000000 --- a/packages/manager/.changeset/pr-13035-changed-1761764242108.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Replace table and paginator in DBaaS with CDS web components ([#12989](https://github.com/linode/manager/pull/12989)) diff --git a/packages/manager/.changeset/pr-13039-fixed-1761836949620.md b/packages/manager/.changeset/pr-13039-fixed-1761836949620.md deleted file mode 100644 index 92402b4fe46..00000000000 --- a/packages/manager/.changeset/pr-13039-fixed-1761836949620.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Enhance `enabled` checks for queries ran within `useQueryWithPermissions` ([#13039](https://github.com/linode/manager/pull/13039)) diff --git a/packages/ui/.changeset/pr-12988-fixed-1760538879992.md b/packages/ui/.changeset/pr-12988-fixed-1760538879992.md deleted file mode 100644 index 04bf293b4a1..00000000000 --- a/packages/ui/.changeset/pr-12988-fixed-1760538879992.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/ui": Fixed ---- - -Misaligned focus indicator on the Toggle component causing visual inconsistency when navigating via keyboard ([#12988](https://github.com/linode/manager/pull/12988)) diff --git a/packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md b/packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md deleted file mode 100644 index 98a1993c429..00000000000 --- a/packages/validation/.changeset/pr-12984-upcoming-features-1761308818735.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Upcoming Features ---- - -Add validation schemas for creating and updating Sharegroup Members and Tokens ([#12984](https://github.com/linode/manager/pull/12984)) diff --git a/packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md b/packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md deleted file mode 100644 index 30c3ef1e3cb..00000000000 --- a/packages/validation/.changeset/pr-12985-upcoming-features-1761308567524.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Upcoming Features ---- - -Add validation schemas for creating and updating Sharegroups and Sharegroup Images ([#12985](https://github.com/linode/manager/pull/12985)) From 0469474b1520655fb98290713f85ff77b9bbf785 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Thu, 30 Oct 2025 13:23:36 -0400 Subject: [PATCH 38/39] Cloud version 1.154.0, API v4 version 0.152.0, Validation version 0.78.0, UI version 0.23.0 --- packages/api-v4/CHANGELOG.md | 9 ++++++ packages/api-v4/package.json | 2 +- packages/manager/CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++ packages/manager/package.json | 2 +- packages/ui/CHANGELOG.md | 7 +++++ packages/ui/package.json | 2 +- packages/validation/CHANGELOG.md | 7 +++++ packages/validation/package.json | 2 +- 8 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 53cbaae5811..db920f492fb 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,12 @@ +## [2025-11-04] - v0.152.0 + + +### Upcoming Features: + +- Add endpoints for `/v4/images/sharegroups/members` and `/v4/images/sharegroups/tokens` ([#12984](https://github.com/linode/manager/pull/12984)) +- Add endpoints for `/v4/images/sharegroups` and `/v4/images/sharegroups/images` ([#12985](https://github.com/linode/manager/pull/12985)) +- Add new `filters` prop in AclpWidget type and update `Filters` type to use `DimensionFilterOperatorType` for operator ([#13006](https://github.com/linode/manager/pull/13006)) + ## [2025-10-21] - v0.151.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index d7a53a25e4f..30b01a473a0 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.151.0", + "version": "0.152.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 3331204a547..25822ea9ff4 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-11-04] - v1.154.0 + +### Added: + +- IAM Parent/Child: hide User details tab for delegate user and add a badge ([#12982](https://github.com/linode/manager/pull/12982)) + +### Changed: + +- IAM Parent/Child: redirect route /delegations for non-parent users ([#13007](https://github.com/linode/manager/pull/13007)) +- Prevent database queries from sending legacy filter and remove unused banner components ([#13015](https://github.com/linode/manager/pull/13015)) +- Replace table and paginator in DBaaS with CDS web components ([#12989](https://github.com/linode/manager/pull/12989)) + +### Fixed: + +- NodeBalancer Configuration form unresponsiveness for larger VPC deployments ([#12991](https://github.com/linode/manager/pull/12991)) +- IAM Roles Table styles and responsive enhancements ([#12997](https://github.com/linode/manager/pull/12997)) +- IAM Account Delegation Tables sorting & filtering ([#13003](https://github.com/linode/manager/pull/13003)) +- IAM - Ensure useEntitiesPermissions does not run for admin users ([#13012](https://github.com/linode/manager/pull/13012)) +- IAM Parent/Child: fix spacing and add notification ([#13013](https://github.com/linode/manager/pull/13013)) +- Upcoming maintenance "When" shows time until start using start_time or policy‑derived start; shows "X days Y hours" when ≥ 1 day ([#13020](https://github.com/linode/manager/pull/13020)) +- Add self-service maintenance action in LinodeMaintenanceBanner for power_off_on and include all maintenance types in dev tools preset ([#13024](https://github.com/linode/manager/pull/13024)) +- IAM: Linodes without required permissions visible and selectable in Assign/Unassign Linodes selector ([#13030](https://github.com/linode/manager/pull/13030)) +- Enhance `enabled` checks for queries ran within `useQueryWithPermissions` ([#13039](https://github.com/linode/manager/pull/13039)) +- Add new `mtc` feature flag, extend it to support valid regions for MTC Linode Migration, and replace the invalid region ID `no-east` ([#13026](https://github.com/linode/manager/pull/13026)) + +### Tech Stories: + +- Update Vite to v7 ([#12792](https://github.com/linode/manager/pull/12792)) +- Upgrade Cypress to v15.4.0 ([#12824](https://github.com/linode/manager/pull/12824)) + +### Tests: + +- Add Linode Interface related tests: deleting an interface, editing interfaces, and updating interface settings ([#12876](https://github.com/linode/manager/pull/12876)) +- Fix "lke-update.spec.ts" LKE-E node pool drawer test that's broken in DevCloud ([#12884](https://github.com/linode/manager/pull/12884)) +- Add Logs Destination Landing, Create and Edit e2e tests ([#12936](https://github.com/linode/manager/pull/12936)) + +### Upcoming Features: + +- IAM: Account Delegations Drawer ([#12970](https://github.com/linode/manager/pull/12970)) +- IAM: Default Roles Table ([#12990](https://github.com/linode/manager/pull/12990)) +- Add support for `privateImageSharing` feature flag for Private Image Sharing feature ([#12992](https://github.com/linode/manager/pull/12992)) +- Logs Delivery Destinations/Stream Delete confirmation modal error state reset fix ([#12996](https://github.com/linode/manager/pull/12996)) +- Stream form bug fixes ([#12999](https://github.com/linode/manager/pull/12999)) +- Add type, utility and mock setup for supporting widget level dimension filters ([#13006](https://github.com/linode/manager/pull/13006)) +- IAM Delegation: Parent Account UI fix ([#13011](https://github.com/linode/manager/pull/13011)) +- CloudPulse-Metrics: Update `FilterConfig.ts` to make firewall a single-select filter and to filter firewalls based on dashboard ([#13014](https://github.com/linode/manager/pull/13014)) +- ACLP-Alerting: Add hook to cleanup stale value from Alerting form ([#13018](https://github.com/linode/manager/pull/13018)) +- CloudPulse-Metrics: Hide scroll bar for filters in all browsers, introduce shared prop in `styles.ts`. ([#13028](https://github.com/linode/manager/pull/13028)) +- CloudPulse-Metrics: Add optional-filter component at `CloudPulseFirewallNodebalancersSelect.tsx` and integrate it with existing firewall-nodebalancer filters ([#13029](https://github.com/linode/manager/pull/13029)) + ## [2025-10-28] - v1.153.2 ### Changed: diff --git a/packages/manager/package.json b/packages/manager/package.json index d6d568525bb..4079788a8d8 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.153.2", + "version": "1.154.0", "private": true, "type": "module", "bugs": { diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 1e36e42671e..ce399a43343 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-11-04] - v0.23.0 + + +### Fixed: + +- Misaligned focus indicator on the Toggle component causing visual inconsistency when navigating via keyboard ([#12988](https://github.com/linode/manager/pull/12988)) + ## [2025-10-21] - v0.22.0 diff --git a/packages/ui/package.json b/packages/ui/package.json index a9a3a40c939..8396ba40bf9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.22.0", + "version": "0.23.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 46691a4d598..176d6748a14 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-11-04] - v0.78.0 + +### Upcoming Features: + +- Add validation schemas for creating and updating Sharegroup Members and Tokens ([#12984](https://github.com/linode/manager/pull/12984)) +- Add validation schemas for creating and updating Sharegroups and Sharegroup Images ([#12985](https://github.com/linode/manager/pull/12985)) + ## [2025-10-21] - v0.77.0 ### Changed: diff --git a/packages/validation/package.json b/packages/validation/package.json index fd357b0f4d7..07ae8252d08 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.77.0", + "version": "0.78.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", From 9e9284af194ae2a0fef838c65eca7413483c3e79 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:38:30 -0400 Subject: [PATCH 39/39] fix: [M3-10698] - Update linode/clone endpoint and label calculations for < 1 hour (#13045) * fix: [M3-9498] - Update linode/clone endpoint and label calculations for < 1 hour * Add changelogs --------- Co-authored-by: Jaalah Ramos --- packages/api-v4/CHANGELOG.md | 4 ++ packages/api-v4/src/linodes/actions.ts | 4 +- packages/manager/CHANGELOG.md | 2 +- .../Account/Maintenance/utilities.test.ts | 20 +++++++++ .../features/Account/Maintenance/utilities.ts | 41 ++++++++++++++++--- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index db920f492fb..f20aa9cbf00 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,6 +1,10 @@ ## [2025-11-04] - v0.152.0 +### Changed: + +- Change `/linode/instances//clone` endpoint to use `v4beta` ([#13045](https://github.com/linode/manager/pull/13045)) + ### Upcoming Features: - Add endpoints for `/v4/images/sharegroups/members` and `/v4/images/sharegroups/tokens` ([#12984](https://github.com/linode/manager/pull/12984)) diff --git a/packages/api-v4/src/linodes/actions.ts b/packages/api-v4/src/linodes/actions.ts index b06b7395acc..0c13ae6e645 100644 --- a/packages/api-v4/src/linodes/actions.ts +++ b/packages/api-v4/src/linodes/actions.ts @@ -1,6 +1,6 @@ import { RebuildLinodeSchema } from '@linode/validation/lib/linodes.schema'; -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, setURL } from '../request'; import type { @@ -185,7 +185,7 @@ export const rescueMetalLinode = (linodeId: number): Promise<{}> => export const cloneLinode = (sourceLinodeId: number, data: LinodeCloneData) => { return Request( setURL( - `${API_ROOT}/linode/instances/${encodeURIComponent(sourceLinodeId)}/clone`, + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent(sourceLinodeId)}/clone`, ), setMethod('POST'), setData(data), diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 25822ea9ff4..d19d5e3d253 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -23,7 +23,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - IAM Account Delegation Tables sorting & filtering ([#13003](https://github.com/linode/manager/pull/13003)) - IAM - Ensure useEntitiesPermissions does not run for admin users ([#13012](https://github.com/linode/manager/pull/13012)) - IAM Parent/Child: fix spacing and add notification ([#13013](https://github.com/linode/manager/pull/13013)) -- Upcoming maintenance "When" shows time until start using start_time or policy‑derived start; shows "X days Y hours" when ≥ 1 day ([#13020](https://github.com/linode/manager/pull/13020)) +- Upcoming maintenance "When" shows time until start using start_time or policy‑derived start; shows "X days Y hours" when ≥ 1 day ([#13020](https://github.com/linode/manager/pull/13020), [#13045](https://github.com/linode/manager/pull/13045)) - Add self-service maintenance action in LinodeMaintenanceBanner for power_off_on and include all maintenance types in dev tools preset ([#13024](https://github.com/linode/manager/pull/13024)) - IAM: Linodes without required permissions visible and selectable in Assign/Unassign Linodes selector ([#13030](https://github.com/linode/manager/pull/13030)) - Enhance `enabled` checks for queries ran within `useQueryWithPermissions` ([#13039](https://github.com/linode/manager/pull/13039)) diff --git a/packages/manager/src/features/Account/Maintenance/utilities.test.ts b/packages/manager/src/features/Account/Maintenance/utilities.test.ts index 09e022ae4f8..b2c19304751 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.test.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.test.ts @@ -132,5 +132,25 @@ describe('Account Maintenance utilities', () => { const label = getUpcomingRelativeLabel(m, policies); expect(label).toBe('in 2 days 16 hours'); }); + + it('shows exact minutes when under one hour', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + // NOW is 12:00Z; start in 37 minutes + start_time: '2025-10-27T12:37:00.000Z', + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 37 minutes'); + }); + + it('shows seconds when under one minute', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + // NOW is 12:00Z; start in 30 seconds + start_time: '2025-10-27T12:00:30.000Z', + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 30 seconds'); + }); }); }); diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index 95c9bb8ca33..9d751506a38 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -83,9 +83,17 @@ export const deriveMaintenanceStartISO = ( /** * Build a user-friendly relative label for the Upcoming table. - * - Prefers the actual/derived start time to express time until maintenance - * - Falls back to the notice relative time when start cannot be determined + * + * Behavior: + * - Prefers the actual or policy-derived start time to express time until maintenance + * - Falls back to the notice relative time when the start cannot be determined * - Avoids day-only rounding by showing days + hours when >= 1 day + * + * Formatting rules: + * - "in X days Y hours" when >= 1 day + * - "in X hours" when >= 1 hour and < 1 day + * - "in N minutes" when < 1 hour + * - "in N seconds" when < 1 minute */ export const getUpcomingRelativeLabel = ( maintenance: AccountMaintenance, @@ -107,18 +115,39 @@ export const getUpcomingRelativeLabel = ( return startDT.toRelative() ?? '—'; } - // Avoid day-only rounding near boundaries by including hours alongside days - const diff = startDT.diff(now, ['days', 'hours']).toObject(); + // Avoid day-only rounding near boundaries by including hours alongside days. + // For times under an hour, show exact minutes remaining; under a minute, show seconds. + const diff = startDT + .diff(now, ['days', 'hours', 'minutes', 'seconds']) + .toObject(); let days = Math.floor(diff.days ?? 0); - let hours = Math.round(diff.hours ?? 0); + let hours = Math.floor(diff.hours ?? 0); + let minutes = Math.round(diff.minutes ?? 0); + const seconds = Math.round(diff.seconds ?? 0); + + // Normalize minute/hour boundaries + if (minutes === 60) { + hours += 1; + minutes = 0; + } if (hours === 24) { days += 1; hours = 0; } + if (days >= 1) { const dayPart = pluralize('day', 'days', days); const hourPart = hours ? ` ${pluralize('hour', 'hours', hours)}` : ''; return `in ${dayPart}${hourPart}`; } - return startDT.toRelative({ unit: 'hours', round: true }) ?? '—'; + + if (hours >= 1) { + return `in ${pluralize('hour', 'hours', hours)}`; + } + + // Under one hour: show minutes; under one minute: show seconds + if (minutes === 0) { + return `in ${pluralize('second', 'seconds', Math.max(0, seconds))}`; + } + return `in ${pluralize('minute', 'minutes', Math.max(0, minutes))}`; };