From 4da657ea0f75380559fb004ea4d7dbc2fb9e725c Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:03:10 -0400 Subject: [PATCH 01/59] fix: [M3-10527] - Maintenance banner showing redundant links (#12763) * fix: [M3-10527] - Maintenance banner showing redundant links * Added changeset: Prevent maintenance banner links from showing when already on /maintenance route * Remove pathname for useLocation * Fix specs --------- Co-authored-by: Jaalah Ramos --- .../pr-12763-fixed-1756132506947.md | 5 ++ .../qemu-reboot-upgrade-notice.spec.ts | 6 +-- .../MaintenanceBannerV2.test.tsx | 46 ++++++++++++++++ .../MaintenanceBanner/MaintenanceBannerV2.tsx | 14 ++--- .../PlatformMaintenanceBanner.test.tsx | 54 +++++++++++++++++++ .../PlatformMaintenanceBanner.tsx | 18 +++---- packages/manager/src/dev-tools/constants.ts | 2 +- .../src/features/Account/AccountLanding.tsx | 4 +- .../AccountSettingsLanding.tsx | 4 +- .../Billing/BillingLanding/BillingLanding.tsx | 4 +- .../LoginHistory/LoginHistoryLanding.tsx | 4 +- .../Maintenance/MaintenanceLanding.tsx | 4 +- .../src/features/Quotas/QuotasLanding.tsx | 4 +- .../ServiceTransfersLanding.tsx | 4 +- .../UsersAndGrants/UsersAndGrants.tsx | 4 +- 15 files changed, 141 insertions(+), 36 deletions(-) create mode 100644 packages/manager/.changeset/pr-12763-fixed-1756132506947.md diff --git a/packages/manager/.changeset/pr-12763-fixed-1756132506947.md b/packages/manager/.changeset/pr-12763-fixed-1756132506947.md new file mode 100644 index 00000000000..00e29a5de23 --- /dev/null +++ b/packages/manager/.changeset/pr-12763-fixed-1756132506947.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Prevent maintenance banner links from showing when already on /maintenance route ([#12763](https://github.com/linode/manager/pull/12763)) diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts index d1bbfc963b8..8ceae07c5a0 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts @@ -109,7 +109,7 @@ describe('QEMU reboot upgrade notification', () => { // Confirm that the notice is visible and contains the expected message cy.findByText(NOTIFICATION_BANNER_TEXT, { exact: false }) .should('be.visible') - .closest('[data-testid="notice-warning"]') + .closest('[data-testid="platform-maintenance-banner"]') .within(() => { cy.get('p').then(($el) => { const noticeText = $el.text(); @@ -321,7 +321,7 @@ describe('QEMU reboot upgrade notification', () => { // Confirm that the notice is visible and contains the expected message cy.findByText(NOTIFICATION_BANNER_TEXT, { exact: false }) .should('be.visible') - .closest('[data-testid="notice-warning"]') + .closest('[data-testid="platform-maintenance-banner"]') .within(() => { cy.get('p').then(($el) => { const noticeText = $el.text(); @@ -329,7 +329,7 @@ describe('QEMU reboot upgrade notification', () => { }); }); cy.findByText(' upcoming', { exact: false }) - .closest('[data-testid="notice-warning"]') + .closest('[data-testid="maintenance-banner"]') .should('be.visible') .within(() => { cy.get('p').then(($el) => { diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx index a29cb207b90..a8e6cef1d13 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx @@ -81,4 +81,50 @@ describe('MaintenanceBannerV2', () => { ) ).not.toBeInTheDocument(); }); + + it('does not show Account Maintenance link when pathname is /maintenance', () => { + const mockMaintenance = [ + accountMaintenanceFactory.build({ + type: 'reboot', + entity: { type: 'linode', id: 123 }, + reason: 'Scheduled maintenance', + status: 'pending', + description: 'scheduled', + }), + ]; + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockMaintenance, + }); + + const { queryByTestId } = renderWithTheme(, { + initialRoute: '/maintenance', + }); + + // Should show the maintenance banner but not the maintenance link section + expect(queryByTestId('maintenance-link-section')).not.toBeInTheDocument(); + }); + + it('shows Account Maintenance link when pathname is not /maintenance', () => { + const mockMaintenance = [ + accountMaintenanceFactory.build({ + type: 'reboot', + entity: { type: 'linode', id: 123 }, + reason: 'Scheduled maintenance', + status: 'pending', + description: 'scheduled', + }), + ]; + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockMaintenance, + }); + + const { getByTestId } = renderWithTheme(, { + initialRoute: '/dashboard', + }); + + // Should show the maintenance banner AND the maintenance link section + getByTestId('maintenance-link-section'); + }); }); diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx index fb10e4e65cf..ae4dafee713 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx @@ -1,6 +1,7 @@ import { useAllAccountMaintenanceQuery } from '@linode/queries'; import { Notice, Typography } from '@linode/ui'; import { pluralize } from '@linode/utilities'; +import { useLocation } from '@tanstack/react-router'; import React from 'react'; import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities'; @@ -8,13 +9,14 @@ import { isPlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; import { Link } from '../Link'; -export const MaintenanceBannerV2 = ({ pathname }: { pathname?: string }) => { +export const MaintenanceBannerV2 = () => { const { data: allMaintenance } = useAllAccountMaintenanceQuery( {}, PENDING_MAINTENANCE_FILTER ); + const location = useLocation(); - const hideAccountMaintenanceLink = pathname === '/account/maintenance'; + const hideAccountMaintenanceLink = location.pathname === '/maintenance'; // Filter out platform maintenance, since that is handled separately const linodeMaintenance = @@ -30,7 +32,7 @@ export const MaintenanceBannerV2 = ({ pathname }: { pathname?: string }) => { return ( maintenanceLinodes.size > 0 && ( - + {pluralize('Linode', 'Linodes', maintenanceLinodes.size)} @@ -38,11 +40,11 @@ export const MaintenanceBannerV2 = ({ pathname }: { pathname?: string }) => { {maintenanceLinodes.size === 1 ? 'has' : 'have'} upcoming{' '} scheduled maintenance. {!hideAccountMaintenanceLink && ( - <> + {' '} For more details, view{' '} - Account Maintenance. - + Account Maintenance. + )} diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx index e692f09bbeb..306e83e4635 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.test.tsx @@ -104,4 +104,58 @@ describe('PlatformMaintenanceBanner', () => { ) ).toBeVisible(); }); + + it('does not show Account Maintenance link when pathname is /maintenance', () => { + const mockPlatformMaintenance = accountMaintenanceFactory.buildList(1, { + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Your Linode needs a critical security update', + }); + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockPlatformMaintenance, + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { queryByTestId } = renderWithTheme(, { + initialRoute: '/maintenance', + }); + + // Should show the platform maintenance banner but not the maintenance link section + expect( + queryByTestId('platform-maintenance-link-section') + ).not.toBeInTheDocument(); + }); + + it('shows Account Maintenance link when pathname is not /maintenance', () => { + const mockPlatformMaintenance = accountMaintenanceFactory.buildList(1, { + type: 'reboot', + entity: { type: 'linode' }, + reason: 'Your Linode needs a critical security update', + }); + + queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({ + data: mockPlatformMaintenance, + }); + + queryMocks.useNotificationsQuery.mockReturnValue({ + data: notificationFactory.buildList(1, { + type: 'security_reboot_maintenance_scheduled', + label: 'Platform Maintenance Scheduled', + }), + }); + + const { getByTestId } = renderWithTheme(, { + initialRoute: '/dashboard', + }); + + // Should show the platform maintenance banner AND the maintenance link section + getByTestId('platform-maintenance-link-section'); + }); }); diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx index 2ee52c46423..27cbfbe5143 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/PlatformMaintenanceBanner.tsx @@ -1,4 +1,5 @@ import { Notice, Typography } from '@linode/ui'; +import { useLocation } from '@tanstack/react-router'; import React from 'react'; import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; @@ -12,20 +13,17 @@ import { Link } from '../Link'; * them separately from the standard MaintenanceBanner. */ -export const PlatformMaintenanceBanner = ({ - pathname, -}: { - pathname?: string; -}) => { +export const PlatformMaintenanceBanner = () => { const { accountHasPlatformMaintenance, linodesWithPlatformMaintenance } = usePlatformMaintenance(); + const location = useLocation(); - const hideAccountMaintenanceLink = pathname === '/account/maintenance'; + const hideAccountMaintenanceLink = location.pathname === '/maintenance'; if (!accountHasPlatformMaintenance) return null; return ( - + {linodesWithPlatformMaintenance.size > 0 @@ -36,11 +34,11 @@ export const PlatformMaintenanceBanner = ({ need{linodesWithPlatformMaintenance.size === 1 && 's'} to be rebooted for critical platform maintenance. {!hideAccountMaintenanceLink && ( - <> + {' '} See which Linodes are scheduled for reboot on the{' '} - Account Maintenance page. - + Account Maintenance page. + )} diff --git a/packages/manager/src/dev-tools/constants.ts b/packages/manager/src/dev-tools/constants.ts index bcd9b539ecf..eab6a17d3fb 100644 --- a/packages/manager/src/dev-tools/constants.ts +++ b/packages/manager/src/dev-tools/constants.ts @@ -28,4 +28,4 @@ export const LOCAL_STORAGE_MAINTENANCE_FORM_DATA_KEY = export const LOCAL_STORAGE_NOTIFICATIONS_FORM_DATA_KEY = 'msw-notifications-form-data'; -export const LOCAL_STORAGE_GRANTS_FORM_DATA_KEY = 'msw-grants-form-data'; \ No newline at end of file +export const LOCAL_STORAGE_GRANTS_FORM_DATA_KEY = 'msw-grants-form-data'; diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 72fc66c37d4..8a702687d98 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -166,8 +166,8 @@ export const AccountLanding = () => { return ( - - + + diff --git a/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx b/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx index 8e854a57be0..4873963450c 100644 --- a/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx +++ b/packages/manager/src/features/AccountSettings/AccountSettingsLanding.tsx @@ -28,8 +28,8 @@ export const AccountSettingsLanding = () => { return ( <> - - + + diff --git a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx index e6104199068..8cf69732bfd 100644 --- a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx +++ b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx @@ -105,8 +105,8 @@ export const BillingLanding = () => { return ( <> - - + + diff --git a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx index f7e8cdc5ffc..64c56618cd6 100644 --- a/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx +++ b/packages/manager/src/features/LoginHistory/LoginHistoryLanding.tsx @@ -27,8 +27,8 @@ export const LoginHistoryLanding = () => { return ( <> - - + + diff --git a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx index cd78b4716bd..5e8625fd98e 100644 --- a/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx +++ b/packages/manager/src/features/Maintenance/MaintenanceLanding.tsx @@ -27,8 +27,8 @@ export const MaintenanceLanding = () => { return ( <> - - + + diff --git a/packages/manager/src/features/Quotas/QuotasLanding.tsx b/packages/manager/src/features/Quotas/QuotasLanding.tsx index eafc6f2237f..75aa29dcdb2 100644 --- a/packages/manager/src/features/Quotas/QuotasLanding.tsx +++ b/packages/manager/src/features/Quotas/QuotasLanding.tsx @@ -33,8 +33,8 @@ export const QuotasLanding = () => { return ( <> - - + + diff --git a/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx b/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx index e5dceef7ae3..1dfef2dcfe1 100644 --- a/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx +++ b/packages/manager/src/features/ServiceTransfers/ServiceTransfersLanding.tsx @@ -28,8 +28,8 @@ export const ServiceTransfersLanding = () => { return ( <> - - + + diff --git a/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx b/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx index 7fd4a68f7c4..2730b045886 100644 --- a/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx +++ b/packages/manager/src/features/UsersAndGrants/UsersAndGrants.tsx @@ -33,8 +33,8 @@ export const UsersAndGrants = () => { return ( <> - - + + From a77c3442ffad5681fd9b622f405744dd7549dd0c Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 5 Sep 2025 09:30:00 +0200 Subject: [PATCH 02/59] change: [UIE-9146] IAM - Improve visual support for AssignedEntities chips with long labels (#12801) * improve support for AssignedEntities chips with long labels * Added changeset: IAM - Improve visual support for AssignedEntities chips with long labels * feedback @mjac0bs --- .../pr-12801-changed-1756893245730.md | 5 ++ .../Users/UserRoles/AssignedEntities.test.tsx | 14 +++++ .../IAM/Users/UserRoles/AssignedEntities.tsx | 63 ++++++++++--------- 3 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 packages/manager/.changeset/pr-12801-changed-1756893245730.md diff --git a/packages/manager/.changeset/pr-12801-changed-1756893245730.md b/packages/manager/.changeset/pr-12801-changed-1756893245730.md new file mode 100644 index 00000000000..14241bd8eb8 --- /dev/null +++ b/packages/manager/.changeset/pr-12801-changed-1756893245730.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM - Improve visual support for AssignedEntities chips with long labels ([#12801](https://github.com/linode/manager/pull/12801)) diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx index fc6814ceadf..8bd82ad79ae 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx @@ -58,4 +58,18 @@ describe('AssignedEntities', () => { mockRole ); }); + + it('renders a tooltip with the entity name when the name is longer than 30 characters', async () => { + const longName = 'this-is-a-long-entity-name-that-needs-to-be-truncated'; + renderWithTheme( + + ); + + await userEvent.hover(screen.getByTestId('entities')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(longName); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 357a5bdc8e3..8559ad920e8 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -73,35 +73,42 @@ export const AssignedEntities = ({ : theme.tokens.spacing.S8, }} > - } - label={ - entity.name.length > 20 - ? `${entity.name.slice(0, 20)}...` - : entity.name - } - onDelete={() => onRemoveAssignment(entity, role)} - sx={{ - backgroundColor: - theme.name === 'light' - ? theme.tokens.color.Ultramarine[20] - : theme.tokens.color.Neutrals.Black, - color: theme.tokens.alias.Content.Text.Primary.Default, - '& .MuiChip-deleteIcon': { + 30 ? entity.name : null} + > + } + label={ + entity.name.length > 30 + ? `${entity.name.slice(0, 20)}...` + : entity.name + } + onDelete={() => onRemoveAssignment(entity, role)} + sx={{ + backgroundColor: + theme.name === 'light' + ? theme.tokens.color.Ultramarine[20] + : theme.tokens.color.Neutrals.Black, color: theme.tokens.alias.Content.Text.Primary.Default, - }, - position: 'relative', - '&::after': { - content: - numHiddenItems > 0 && isLastVisibleItem(index) ? '"..."' : '""', - position: 'absolute', - top: 0, - right: -16, - width: 14, - }, - }} - /> + '& .MuiChip-deleteIcon': { + color: theme.tokens.alias.Content.Text.Primary.Default, + }, + position: 'relative', + '&::after': { + content: + numHiddenItems > 0 && isLastVisibleItem(index) + ? '"..."' + : '""', + position: 'absolute', + top: 0, + right: -16, + width: 14, + }, + }} + /> + ) ); From 75e59a16fdfd3e926cb07e177545304d71208789 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Fri, 5 Sep 2025 10:09:06 +0200 Subject: [PATCH 03/59] new: [STORIF-82] Volume tag editing feature added to the volume details page. (#12800) * new: [STORIF-82] Volume tag editing feature added to the volume details page. * Added changesets for "manager" and "api-v4" --- .../pr-12800-changed-1756894123495.md | 5 +++ packages/api-v4/src/volumes/volumes.ts | 2 +- .../pr-12800-added-1756893991385.md | 5 +++ .../Volumes/Drawers/EditVolumeDrawer.tsx | 7 ++-- .../Volumes/Drawers/ManageTagsDrawer.tsx | 14 +++---- .../VolumeEntityDetail.tsx | 2 +- .../VolumeEntityDetailFooter.tsx | 42 ++++++++++++++----- packages/queries/src/volumes/volumes.ts | 10 ++--- 8 files changed, 57 insertions(+), 30 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12800-changed-1756894123495.md create mode 100644 packages/manager/.changeset/pr-12800-added-1756893991385.md diff --git a/packages/api-v4/.changeset/pr-12800-changed-1756894123495.md b/packages/api-v4/.changeset/pr-12800-changed-1756894123495.md new file mode 100644 index 00000000000..ced1f9d116a --- /dev/null +++ b/packages/api-v4/.changeset/pr-12800-changed-1756894123495.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +UpdateVolumeRequest updated ([#12800](https://github.com/linode/manager/pull/12800)) diff --git a/packages/api-v4/src/volumes/volumes.ts b/packages/api-v4/src/volumes/volumes.ts index 68b4f768bdb..6aac6b7b2be 100644 --- a/packages/api-v4/src/volumes/volumes.ts +++ b/packages/api-v4/src/volumes/volumes.ts @@ -152,7 +152,7 @@ export const resizeVolume = (volumeId: number, data: ResizeVolumePayload) => ); export interface UpdateVolumeRequest { - label: string; + label?: string; tags?: string[]; } diff --git a/packages/manager/.changeset/pr-12800-added-1756893991385.md b/packages/manager/.changeset/pr-12800-added-1756893991385.md new file mode 100644 index 00000000000..9c93610b024 --- /dev/null +++ b/packages/manager/.changeset/pr-12800-added-1756893991385.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Ability to edit tags on volume details page ([#12800](https://github.com/linode/manager/pull/12800)) diff --git a/packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.tsx index b3567416414..fcadfd32e65 100644 --- a/packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.tsx @@ -1,4 +1,4 @@ -import { useUpdateVolumeMutation } from '@linode/queries'; +import { useVolumeUpdateMutation } from '@linode/queries'; import { ActionsPanel, Box, @@ -39,7 +39,9 @@ export const EditVolumeDrawer = (props: Props) => { ); const canUpdateVolume = permissions?.update_volume; - const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); + const { mutateAsync: updateVolume } = useVolumeUpdateMutation( + volume?.id ?? -1 + ); const { isBlockStorageEncryptionFeatureEnabled } = useIsBlockStorageEncryptionFeatureEnabled(); @@ -62,7 +64,6 @@ export const EditVolumeDrawer = (props: Props) => { await updateVolume({ label: values.label, tags: values.tags, - volumeId: volume?.id ?? -1, }); onClose(); diff --git a/packages/manager/src/features/Volumes/Drawers/ManageTagsDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/ManageTagsDrawer.tsx index f887fed725e..a25793548e7 100644 --- a/packages/manager/src/features/Volumes/Drawers/ManageTagsDrawer.tsx +++ b/packages/manager/src/features/Volumes/Drawers/ManageTagsDrawer.tsx @@ -1,4 +1,4 @@ -import { useUpdateVolumeMutation } from '@linode/queries'; +import { useVolumeUpdateMutation } from '@linode/queries'; import { ActionsPanel, Drawer, Notice } from '@linode/ui'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -26,7 +26,9 @@ export const ManageTagsDrawer = (props: Props) => { ); const canUpdateVolume = permissions?.update_volume; - const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); + const { mutateAsync: updateVolume } = useVolumeUpdateMutation( + volume?.id ?? -1 + ); const { control, @@ -40,16 +42,12 @@ export const ManageTagsDrawer = (props: Props) => { const onSubmit = handleSubmit(async (values) => { try { - await updateVolume({ - label: volume?.label ?? '', - tags: values.tags, - volumeId: volume?.id ?? -1, - }); + await updateVolume({ tags: values.tags }); onClose(); } catch (errors) { errors.forEach((error: APIError) => { - if (error.field == 'tags') { + if (error.field === 'tags') { setError('tags', { message: error.reason, }); diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx index ccbabeda79d..6108aabd8e9 100644 --- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetail.tsx @@ -16,7 +16,7 @@ export const VolumeEntityDetail = ({ volume }: Props) => { return ( } - footer={} + footer={} header={} /> ); diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx index eb974fe1c4b..1e877a265b5 100644 --- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeEntityDetails/VolumeEntityDetailFooter.tsx @@ -1,27 +1,49 @@ +import { useVolumeUpdateMutation } from '@linode/queries'; +import { useSnackbar } from 'notistack'; import React from 'react'; import { TagCell } from 'src/components/TagCell/TagCell'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Volume } from '@linode/api-v4'; interface Props { - tags: string[]; + volume: Volume; } -export const VolumeEntityDetailFooter = ({ tags }: Props) => { - const isReadOnlyAccountAccess = useRestrictedGlobalGrantCheck({ - globalGrantType: 'account_access', - permittedGrantLevel: 'read_write', - }); +export const VolumeEntityDetailFooter = ({ volume }: Props) => { + const { enqueueSnackbar } = useSnackbar(); + const { mutateAsync: updateVolume } = useVolumeUpdateMutation(volume.id); + const { data: volumePermissions } = usePermissions( + 'volume', + ['update_volume'], + volume.id + ); + + const updateTags = React.useCallback( + async (tags: string[]) => { + return updateVolume({ tags }).catch((e) => + enqueueSnackbar( + getAPIErrorOrDefault(e, 'Error updating tags')[0].reason, + { + variant: 'error', + } + ) + ); + }, + [updateVolume, enqueueSnackbar] + ); return ( Promise.resolve()} + tags={volume.tags} + updateTags={updateTags} view="inline" /> ); diff --git a/packages/queries/src/volumes/volumes.ts b/packages/queries/src/volumes/volumes.ts index afa7a92d9d9..2b35ee3e4fb 100644 --- a/packages/queries/src/volumes/volumes.ts +++ b/packages/queries/src/volumes/volumes.ts @@ -228,14 +228,10 @@ export const useVolumesMigrateMutation = () => { }); }; -interface UpdateVolumePayloadWithId extends UpdateVolumeRequest { - volumeId: number; -} - -export const useUpdateVolumeMutation = () => { +export const useVolumeUpdateMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ volumeId, ...data }) => updateVolume(volumeId, data), + return useMutation({ + mutationFn: (data) => updateVolume(id, data), onSuccess(volume) { // Update the specific volume queryClient.setQueryData( From a9e02fb395f3295d0639d79eeaefb2ee9c74205a Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Fri, 5 Sep 2025 10:18:22 +0200 Subject: [PATCH 04/59] upcoming: [DPS-34460] - Include streams and destinations in search bar (#12811) --- ...r-12811-upcoming-features-1756992961670.md | 5 ++ .../Delivery/StreamFormDelivery.tsx | 16 ++--- .../features/DataStream/dataStreamUtils.ts | 15 +++- .../src/features/Search/search.interfaces.ts | 4 ++ .../src/features/Search/useAPISearch.ts | 22 +++++- .../features/Search/useClientSideSearch.ts | 25 ++++++- .../manager/src/features/Search/utils.test.ts | 6 ++ packages/manager/src/features/Search/utils.ts | 11 +++ .../src/store/selectors/getSearchEntities.ts | 29 ++++++++ ...r-12811-upcoming-features-1756993033947.md | 5 ++ .../queries/src/datastreams/datastream.ts | 69 +++++++++++++++++-- 11 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 packages/manager/.changeset/pr-12811-upcoming-features-1756992961670.md create mode 100644 packages/queries/.changeset/pr-12811-upcoming-features-1756993033947.md diff --git a/packages/manager/.changeset/pr-12811-upcoming-features-1756992961670.md b/packages/manager/.changeset/pr-12811-upcoming-features-1756992961670.md new file mode 100644 index 00000000000..f5bcc87a84e --- /dev/null +++ b/packages/manager/.changeset/pr-12811-upcoming-features-1756992961670.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Streams and Destinations to search bar ([#12811](https://github.com/linode/manager/pull/12811)) diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index d4d7cbf4f24..cc72f14e3b6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -48,13 +48,13 @@ export const StreamFormDelivery = () => { React.useState(false); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); - const destinationNameOptions: DestinationName[] = (destinations || []).map( - ({ id, label, type }) => ({ - id, - label, - type, - }) - ); + const destinationNameOptions: DestinationName[] = ( + destinations?.data || [] + ).map(({ id, label, type }) => ({ + id, + label, + type, + })); const selectedDestinationType = useWatch({ control, @@ -153,7 +153,7 @@ export const StreamFormDelivery = () => { )} {!!selectedDestinations?.length && ( id === selectedDestinations[0] )?.details as LinodeObjectStorageDetails)} /> diff --git a/packages/manager/src/features/DataStream/dataStreamUtils.ts b/packages/manager/src/features/DataStream/dataStreamUtils.ts index 50111ba9186..826f74a588f 100644 --- a/packages/manager/src/features/DataStream/dataStreamUtils.ts +++ b/packages/manager/src/features/DataStream/dataStreamUtils.ts @@ -1,4 +1,9 @@ -import { isEmpty, streamType } from '@linode/api-v4'; +import { + type Destination, + isEmpty, + type Stream, + streamType, +} from '@linode/api-v4'; import { omitProps } from '@linode/ui'; import { @@ -40,3 +45,11 @@ export const getStreamPayloadDetails = ( return payloadDetails; }; + +export const getStreamDescription = (stream: Stream) => { + return `${getStreamTypeOption(stream.type)?.label}`; +}; + +export const getDestinationDescription = (destination: Destination) => { + return `${getDestinationTypeOption(destination.type)?.label}`; +}; diff --git a/packages/manager/src/features/Search/search.interfaces.ts b/packages/manager/src/features/Search/search.interfaces.ts index c6cbbe38a8d..f9c12f00a2e 100644 --- a/packages/manager/src/features/Search/search.interfaces.ts +++ b/packages/manager/src/features/Search/search.interfaces.ts @@ -21,6 +21,7 @@ export interface SearchableItem { export type SearchableEntityType = | 'bucket' | 'database' + | 'destination' | 'domain' | 'firewall' | 'image' @@ -28,6 +29,7 @@ export type SearchableEntityType = | 'linode' | 'nodebalancer' | 'stackscript' + | 'stream' | 'volume'; // These are the properties on our entities we'd like to search @@ -36,6 +38,7 @@ export type SearchField = 'ips' | 'label' | 'tags' | 'type' | 'value'; export interface SearchResultsByEntity { bucket: SearchableItem[]; database: SearchableItem[]; + destination: SearchableItem[]; domain: SearchableItem[]; firewall: SearchableItem[]; image: SearchableItem[]; @@ -43,5 +46,6 @@ export interface SearchResultsByEntity { linode: SearchableItem[]; nodebalancer: SearchableItem[]; stackscript: SearchableItem[]; + stream: SearchableItem[]; volume: SearchableItem[]; } diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index b6ee0af983e..9c225c188d3 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -1,5 +1,6 @@ import { useDatabasesInfiniteQuery, + useDestinationsInfiniteQuery, useDomainsInfiniteQuery, useFirewallsInfiniteQuery, useImagesInfiniteQuery, @@ -7,6 +8,7 @@ import { useInfiniteNodebalancersQuery, useInfiniteVolumesQuery, useStackScriptsInfiniteQuery, + useStreamsInfiniteQuery, } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { useDebouncedValue } from '@linode/utilities'; @@ -14,6 +16,7 @@ import { useDebouncedValue } from '@linode/utilities'; import { useKubernetesClustersInfiniteQuery } from 'src/queries/kubernetes'; import { databaseToSearchableItem, + destinationToSearchableItem, domainToSearchableItem, firewallToSearchableItem, imageToSearchableItem, @@ -21,6 +24,7 @@ import { linodeToSearchableItem, nodeBalToSearchableItem, stackscriptToSearchableItem, + streamToSearchableItem, volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; @@ -108,11 +112,27 @@ const entities = [ searchableFieldsWithoutOperator: ['label', 'ipv4', 'tags'], }, }, + { + getSearchableItem: streamToSearchableItem, + name: 'stream' as const, + query: useStreamsInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label'], + }, + }, + { + getSearchableItem: destinationToSearchableItem, + name: 'destination' as const, + query: useDestinationsInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label'], + }, + }, ]; /** * Fetches entities on a user's account using server-side filtering - * based on a user's seach query. + * based on a user's search query. * * We have to fetch the first page of each entity because API-v4 * does not provide a dedicated search endpoint. diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index d0e8b099bbf..7c5cf8e0706 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -1,11 +1,13 @@ import { useAllAccountStackScriptsQuery, useAllDatabasesQuery, + useAllDestinationsQuery, useAllDomainsQuery, useAllFirewallsQuery, useAllImagesQuery, useAllLinodesQuery, useAllNodeBalancersQuery, + useAllStreamsQuery, useAllVolumesQuery, } from '@linode/queries'; @@ -15,6 +17,7 @@ import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { bucketToSearchableItem, databaseToSearchableItem, + destinationToSearchableItem, domainToSearchableItem, firewallToSearchableItem, imageToSearchableItem, @@ -22,6 +25,7 @@ import { linodeToSearchableItem, nodeBalToSearchableItem, stackscriptToSearchableItem, + streamToSearchableItem, volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; @@ -87,6 +91,16 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { error: stackscriptsError, isLoading: stackscriptsLoading, } = useAllAccountStackScriptsQuery(enabled); + const { + data: streams, + error: streamsError, + isLoading: streamsLoading, + } = useAllStreamsQuery({}, {}, enabled); + const { + data: destinations, + error: destinationsError, + isLoading: destinationsLoading, + } = useAllDestinationsQuery({}, {}, enabled); const searchableDomains = domains?.map(domainToSearchableItem) ?? []; const searchableVolumes = volumes?.map(volumeToSearchableItem) ?? []; @@ -101,6 +115,9 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; const searchableClusters = clusters?.map(kubernetesClusterToSearchableItem) ?? []; + const searchableStreams = streams?.data?.map(streamToSearchableItem) ?? []; + const searchableDestinations = + destinations?.data?.map(destinationToSearchableItem) ?? []; const searchableItems = [ ...searchableLinodes, @@ -113,6 +130,8 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { ...searchableFirewalls, ...searchableDatabases, ...searchableStackScripts, + ...searchableStreams, + ...searchableDestinations, ]; const isLoading = @@ -124,11 +143,14 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { domainsLoading || volumesLoading || firewallsLoading || - stackscriptsLoading; + stackscriptsLoading || + streamsLoading || + destinationsLoading; const entityErrors: Record = { bucket: bucketsError?.message ?? null, database: databasesError?.[0].reason ?? null, + destination: destinationsError?.[0].reason ?? null, domain: domainsError?.[0].reason ?? null, firewall: firewallsError?.[0].reason ?? null, image: imagesError?.[0].reason ?? null, @@ -136,6 +158,7 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { linode: linodesError?.[0].reason ?? null, nodebalancer: nodebalancersError?.[0].reason ?? null, stackscript: stackscriptsError?.[0].reason ?? null, + stream: streamsError?.[0].reason ?? null, volume: volumesError?.[0].reason ?? null, }; diff --git a/packages/manager/src/features/Search/utils.test.ts b/packages/manager/src/features/Search/utils.test.ts index a9a3107e454..373c23989bd 100644 --- a/packages/manager/src/features/Search/utils.test.ts +++ b/packages/manager/src/features/Search/utils.test.ts @@ -18,6 +18,8 @@ describe('separate results by entity', () => { expect(results).toHaveProperty('bucket'); expect(results).toHaveProperty('firewall'); expect(results).toHaveProperty('database'); + expect(results).toHaveProperty('destination'); + expect(results).toHaveProperty('stream'); }); it('the value of each entity type is an array', () => { @@ -30,6 +32,8 @@ describe('separate results by entity', () => { expect(results.bucket).toBeInstanceOf(Array); expect(results.firewall).toBeInstanceOf(Array); expect(results.database).toBeInstanceOf(Array); + expect(results.destination).toBeInstanceOf(Array); + expect(results.stream).toBeInstanceOf(Array); }); it('returns empty results if there is no data', () => { @@ -37,6 +41,7 @@ describe('separate results by entity', () => { expect(newResults).toEqual({ bucket: [], database: [], + destination: [], domain: [], firewall: [], image: [], @@ -44,6 +49,7 @@ describe('separate results by entity', () => { linode: [], nodebalancer: [], stackscript: [], + stream: [], volume: [], }); }); diff --git a/packages/manager/src/features/Search/utils.ts b/packages/manager/src/features/Search/utils.ts index 4fcde240dd2..ab7c157e39b 100644 --- a/packages/manager/src/features/Search/utils.ts +++ b/packages/manager/src/features/Search/utils.ts @@ -1,5 +1,6 @@ import Compute from 'src/assets/icons/entityIcons/compute.svg'; import Database from 'src/assets/icons/entityIcons/database.svg'; +import Monitor from 'src/assets/icons/entityIcons/monitor.svg'; import Networking from 'src/assets/icons/entityIcons/networking.svg'; import Storage from 'src/assets/icons/entityIcons/storage.svg'; @@ -15,6 +16,7 @@ import type { export const emptyResults: SearchResultsByEntity = { bucket: [], database: [], + destination: [], domain: [], firewall: [], image: [], @@ -22,12 +24,14 @@ export const emptyResults: SearchResultsByEntity = { linode: [], nodebalancer: [], stackscript: [], + stream: [], volume: [], }; export const emptyErrors: Record = { bucket: null, database: null, + destination: null, domain: null, firewall: null, image: null, @@ -35,6 +39,7 @@ export const emptyErrors: Record = { linode: null, nodebalancer: null, stackscript: null, + stream: null, volume: null, }; @@ -44,6 +49,7 @@ export const searchableEntityIconMap: Record< > = { bucket: Storage, database: Database, + destination: Monitor, domain: Networking, firewall: Networking, image: Storage, @@ -51,6 +57,7 @@ export const searchableEntityIconMap: Record< linode: Compute, nodebalancer: Networking, stackscript: Compute, + stream: Monitor, volume: Storage, }; @@ -60,6 +67,7 @@ export const searchableEntityDisplayNameMap: Record< > = { bucket: 'Buckets', database: 'Databases', + destination: 'Destination', domain: 'Domains', firewall: 'Firewalls', image: 'Images', @@ -67,6 +75,7 @@ export const searchableEntityDisplayNameMap: Record< linode: 'Linodes', nodebalancer: 'NodeBalancers', stackscript: 'StackScripts', + stream: 'Stream', volume: 'Volumes', }; @@ -92,6 +101,7 @@ export const separateResultsByEntity = ( const separatedResults: SearchResultsByEntity = { bucket: [], database: [], + destination: [], domain: [], firewall: [], image: [], @@ -99,6 +109,7 @@ export const separateResultsByEntity = ( linode: [], nodebalancer: [], stackscript: [], + stream: [], volume: [], }; diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 512163415db..3a57a5339cf 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -2,11 +2,16 @@ import { pluralize } from '@linode/utilities'; import { readableBytes } from '@linode/utilities'; import { getDatabasesDescription } from 'src/features/Databases/utilities'; +import { + getDestinationDescription, + getStreamDescription, +} from 'src/features/DataStream/dataStreamUtils'; import { getFirewallDescription } from 'src/features/Firewalls/shared'; import { getDescriptionForCluster } from 'src/features/Kubernetes/kubeUtils'; import type { DatabaseInstance, + Destination, Domain, Firewall, Image, @@ -15,6 +20,7 @@ import type { NodeBalancer, ObjectStorageBucket, StackScript, + Stream, Volume, } from '@linode/api-v4'; import type { SearchableItem } from 'src/features/Search/search.interfaces'; @@ -195,3 +201,26 @@ export const stackscriptToSearchableItem = ( label: stackscript.label, value: stackscript.id, }); + +export const streamToSearchableItem = (stream: Stream): SearchableItem => ({ + data: { + description: getStreamDescription(stream), + path: `/datastream/streams/${stream.id}/edit`, + status: stream.status, + }, + entityType: 'stream', + label: stream.label, + value: stream.id, +}); + +export const destinationToSearchableItem = ( + destination: Destination +): SearchableItem => ({ + data: { + description: getDestinationDescription(destination), + path: `/datastream/destinations/${destination.id}/edit`, + }, + entityType: 'destination', + label: destination.label, + value: destination.id, +}); diff --git a/packages/queries/.changeset/pr-12811-upcoming-features-1756993033947.md b/packages/queries/.changeset/pr-12811-upcoming-features-1756993033947.md new file mode 100644 index 00000000000..0b4fea6f4ae --- /dev/null +++ b/packages/queries/.changeset/pr-12811-upcoming-features-1756993033947.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Add infiniteQueries for Streams and Destinations ([#12811](https://github.com/linode/manager/pull/12811)) diff --git a/packages/queries/src/datastreams/datastream.ts b/packages/queries/src/datastreams/datastream.ts index ea5d928b7c7..57457f22bc1 100644 --- a/packages/queries/src/datastreams/datastream.ts +++ b/packages/queries/src/datastreams/datastream.ts @@ -13,7 +13,12 @@ import { import { profileQueries } from '@linode/queries'; import { getAll } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import type { APIError, @@ -27,6 +32,7 @@ import type { UpdateDestinationPayloadWithId, UpdateStreamPayloadWithId, } from '@linode/api-v4'; +import type { GetAllData } from '@linode/utilities'; export const getAllDataStreams = ( passedParams: Params = {}, @@ -34,7 +40,7 @@ export const getAllDataStreams = ( ) => getAll((params, filter) => getStreams({ ...params, ...passedParams }, { ...filter, ...passedFilter }), - )().then((data) => data.data); + )(); export const getAllDestinations = ( passedParams: Params = {}, @@ -45,7 +51,7 @@ export const getAllDestinations = ( { ...params, ...passedParams }, { ...filter, ...passedFilter }, ), - )().then((data) => data.data); + )(); export const datastreamQueries = createQueryKeys('datastream', { stream: (id: number) => ({ @@ -58,6 +64,11 @@ export const datastreamQueries = createQueryKeys('datastream', { queryFn: () => getAllDataStreams(params, filter), queryKey: [params, filter], }), + infinite: (filter: Filter) => ({ + queryFn: ({ pageParam }) => + getStreams({ page: pageParam as number }, filter), + queryKey: [filter], + }), paginated: (params: Params, filter: Filter) => ({ queryFn: () => getStreams(params, filter), queryKey: [params, filter], @@ -75,6 +86,11 @@ export const datastreamQueries = createQueryKeys('datastream', { queryFn: () => getAllDestinations(params, filter), queryKey: [params, filter], }), + infinite: (filter: Filter) => ({ + queryFn: ({ pageParam }) => + getDestinations({ page: pageParam as number }, filter), + queryKey: [filter], + }), paginated: (params: Params, filter: Filter) => ({ queryFn: () => getDestinations(params, filter), queryKey: [params, filter], @@ -89,6 +105,31 @@ export const useStreamsQuery = (params: Params = {}, filter: Filter = {}) => ...datastreamQueries.streams._ctx.paginated(params, filter), }); +export const useAllStreamsQuery = ( + params: Params = {}, + filter: Filter = {}, + enabled = true, +) => + useQuery, APIError[]>({ + ...datastreamQueries.streams._ctx.all(params, filter), + enabled, + }); + +export const useStreamsInfiniteQuery = (filter: Filter, enabled: boolean) => { + return useInfiniteQuery, APIError[]>({ + ...datastreamQueries.streams._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + export const useStreamQuery = (id: number) => useQuery({ ...datastreamQueries.stream(id) }); @@ -165,9 +206,11 @@ export const useDeleteStreamMutation = () => { export const useAllDestinationsQuery = ( params: Params = {}, filter: Filter = {}, + enabled = true, ) => - useQuery({ + useQuery, APIError[]>({ ...datastreamQueries.destinations._ctx.all(params, filter), + enabled, }); export const useDestinationsQuery = ( @@ -178,6 +221,24 @@ export const useDestinationsQuery = ( ...datastreamQueries.destinations._ctx.paginated(params, filter), }); +export const useDestinationsInfiniteQuery = ( + filter: Filter, + enabled: boolean, +) => { + return useInfiniteQuery, APIError[]>({ + ...datastreamQueries.destinations._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + export const useDestinationQuery = (id: number) => useQuery({ ...datastreamQueries.destination(id) }); From 3f471d2fc4eccf498411ecb4606e796d105ae0d5 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:55:41 +0200 Subject: [PATCH 05/59] fix: [UIE-8819] IAM - Improve & consolidate username & email validation (#12788) * consolidate username & email validation * test * fix schema deprecation * changesets * feedback @mjac0bs * small schema cleanup * small schema cleanup --- .../pr-12788-fixed-1756821797185.md | 5 + .../IAM/Users/UserDetails/UserEmailPanel.tsx | 5 +- .../IAM/Users/UserDetails/UsernamePanel.tsx | 3 + .../UsersTable/CreateUserDrawer.test.tsx | 6 +- .../IAM/Users/UsersTable/CreateUserDrawer.tsx | 4 +- .../pr-12788-fixed-1756821848689.md | 5 + packages/validation/src/account.schema.ts | 91 ++++++++++--------- packages/validation/src/profile.schema.ts | 6 ++ 8 files changed, 77 insertions(+), 48 deletions(-) create mode 100644 packages/manager/.changeset/pr-12788-fixed-1756821797185.md create mode 100644 packages/validation/.changeset/pr-12788-fixed-1756821848689.md diff --git a/packages/manager/.changeset/pr-12788-fixed-1756821797185.md b/packages/manager/.changeset/pr-12788-fixed-1756821797185.md new file mode 100644 index 00000000000..33f74c9d4c9 --- /dev/null +++ b/packages/manager/.changeset/pr-12788-fixed-1756821797185.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM - Username & email consolidation between create and edit flows - ASCII & chars limit validation + improved messages ([#12788](https://github.com/linode/manager/pull/12788)) diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx index fe6c003ee84..618622b4467 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx @@ -1,5 +1,7 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useMutateProfile, useProfile } from '@linode/queries'; import { Button, Paper, TextField } from '@linode/ui'; +import { UpdateUserEmailSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -27,6 +29,7 @@ export const UserEmailPanel = ({ canUpdateUser, user }: Props) => { handleSubmit, setError, } = useForm({ + resolver: yupResolver(UpdateUserEmailSchema), defaultValues: { email: user.email }, values: { email: user.email }, }); @@ -53,7 +56,7 @@ export const UserEmailPanel = ({ canUpdateUser, user }: Props) => { return ( -
+ { handleSubmit, setError, } = useForm({ + resolver: yupResolver(UpdateUserNameSchema), defaultValues: { username: user.username }, values: { username: user.username }, }); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx index b4b8bf7d270..06cb4589f1b 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx @@ -116,11 +116,11 @@ describe('CreateUserDrawer - Username Validation', () => { }); it('should display error for username with spaces', async () => { - await testUsernameValidation('test user', userNameErrors.charsError); + await testUsernameValidation('test user', userNameErrors.spacesError); }); it('should display error for username with tabs', async () => { - await testUsernameValidation('test\tuser', userNameErrors.charsError); + await testUsernameValidation('test\tuser', userNameErrors.spacesError); }); it('should display error for username with special characters', async () => { @@ -128,7 +128,7 @@ describe('CreateUserDrawer - Username Validation', () => { }); it('should display error for non-ASCII characters', async () => { - await testUsernameValidation('tëstuser', userNameErrors.charsError); + await testUsernameValidation('tëstuser', userNameErrors.nonAsciiError); }); describe('Valid usernames', () => { diff --git a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx index 855648885ea..f2927e6ffcc 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx @@ -54,7 +54,7 @@ export const CreateUserDrawer = (props: Props) => { {errors.root?.message && ( )} - + { data-qa-create-email errorText={fieldState.error?.message} label="Email" + onBlur={field.onBlur} onChange={field.onChange} required trimmed @@ -88,7 +89,6 @@ export const CreateUserDrawer = (props: Props) => { value={field.value} /> )} - rules={{ required: 'Email is required' }} /> diff --git a/packages/validation/.changeset/pr-12788-fixed-1756821848689.md b/packages/validation/.changeset/pr-12788-fixed-1756821848689.md new file mode 100644 index 00000000000..340baec9999 --- /dev/null +++ b/packages/validation/.changeset/pr-12788-fixed-1756821848689.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Fixed +--- + +Username & email ASCII & chars limit validation + improved messages ([#12788](https://github.com/linode/manager/pull/12788)) diff --git a/packages/validation/src/account.schema.ts b/packages/validation/src/account.schema.ts index a2077f4b154..20176eeb841 100644 --- a/packages/validation/src/account.schema.ts +++ b/packages/validation/src/account.schema.ts @@ -1,5 +1,7 @@ import { array, boolean, mixed, number, object, string } from 'yup'; +import { emailSchema } from './profile.schema'; + export const updateAccountSchema = object({ email: string().max(128, 'Email must be 128 characters or less.'), address_1: string().max(64, 'Address must be 64 characters or less.'), @@ -88,54 +90,59 @@ export const userNameErrors = { nonAsciiError: 'Username must only use ASCII characters.', }; -export const CreateUserSchema = object({ - username: string() - .required('Username is required.') - .min(3, userNameErrors.lengthError) - .max(32, userNameErrors.lengthError) - .test('ascii-only', userNameErrors.nonAsciiError, (value) => { - if (!value) return false; - try { - return btoa(value).length > 0; // Simple ASCII check - } catch { - return false; - } - }) - .test( - 'no-consecutive-separators', - userNameErrors.consecutiveError, - (value) => { - if (!value) return true; // Allow empty values (required check handles this) - return !value.includes('__') && !value.includes('--'); - }, - ) - .test('valid-characters', userNameErrors.charsError, (value) => { - if (!value) return false; - - // Check first and last characters (letters or numbers) - const firstChar = value[0]; - const lastChar = value[value.length - 1]; - const isAlphaNum = /[a-zA-Z0-9]/; - - if (!isAlphaNum.test(firstChar) || !isAlphaNum.test(lastChar)) { - return false; - } - - // Check all characters are valid (letters, numbers, dashes, underscores) - return /^[a-zA-Z0-9_-]+$/.test(value); - }) - .test('no-whitespace', userNameErrors.spacesError, (value) => { +const usernameSchema = string() + .required('Username is required.') + .min(3, userNameErrors.lengthError) + .max(32, userNameErrors.lengthError) + .test('ascii-only', userNameErrors.nonAsciiError, (value) => { + if (!value) return false; + // Check if all characters are ASCII (character codes 0-127) + return [...value].every((char) => char.charCodeAt(0) <= 127); + }) + .test('no-whitespace', userNameErrors.spacesError, (value) => { + if (!value) return true; // Allow empty values (required check handles this) + return !/[ \t]/.test(value); + }) + .test( + 'no-consecutive-separators', + userNameErrors.consecutiveError, + (value) => { if (!value) return true; // Allow empty values (required check handles this) - return !/[ \t]/.test(value); - }), - email: string() - .required('Email address is required.') - .email('Must be a valid email address.'), + return !value.includes('__') && !value.includes('--'); + }, + ) + .test('valid-characters', userNameErrors.charsError, (value) => { + if (!value) return false; + + // Check first and last characters (letters or numbers) + const firstChar = value[0]; + const lastChar = value[value.length - 1]; + const isAlphaNum = /[a-zA-Z0-9]/; + + if (!isAlphaNum.test(firstChar) || !isAlphaNum.test(lastChar)) { + return false; + } + + // Check all characters are valid (letters, numbers, dashes, underscores) + return /^[a-zA-Z0-9_-]+$/.test(value); + }); + +export const CreateUserSchema = object({ + username: usernameSchema, + email: emailSchema, restricted: boolean().required( 'You must indicate if this user should have restricted access.', ), }); +export const UpdateUserNameSchema = object({ + username: usernameSchema, +}); + +export const UpdateUserEmailSchema = object({ + email: emailSchema, +}); + export const UpdateUserSchema = object({ username: string() .min(3, userNameErrors.lengthError) diff --git a/packages/validation/src/profile.schema.ts b/packages/validation/src/profile.schema.ts index 29fa41cccdc..2c8d0072d0f 100644 --- a/packages/validation/src/profile.schema.ts +++ b/packages/validation/src/profile.schema.ts @@ -32,6 +32,12 @@ export const updateSSHKeySchema = object({ .trim(), }); +export const emailSchema = string() + .required('Email address is required.') + .max(128, 'Email address must be 128 characters or less.') + .email('Must be a valid email address.') + .matches(EMAIL_VALIDATION_REGEX, `Invalid email address.`); + export const updateProfileSchema = object({ email: string() .email() From bafb2bc065d04ff8b341c74833af118b4cb39090 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:52:49 -0400 Subject: [PATCH 06/59] fix: [M3-10583] - Volume information not populating in Linode Configuration table row (#12809) * initial refactor and fix * fix missing invalidation * fix type issues * organize and add unit testing * fix type issues * fix unit test * Added changeset: Linode configuration row incorrectly representing volume devies * Added changeset: Update `DiskDevice` and `VolumeDevice` to more closely align with the API's behavior * add changesets * Update packages/manager/.changeset/pr-12809-fixed-1756996472770.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * fix: cache updates for volume attach/detach --------- Co-authored-by: Banks Nussman Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-12809-changed-1756996526265.md | 5 + packages/api-v4/src/linodes/types.ts | 6 +- .../pr-12809-fixed-1756996376049.md | 5 + .../pr-12809-fixed-1756996472770.md | 5 + .../Linodes/CloneLanding/utilities.ts | 2 +- .../LinodesDetail/LinodeConfigs/ConfigRow.tsx | 118 +++++------------- .../ConfigRowDevices/ConfigRowDevice.test.tsx | 52 ++++++++ .../ConfigRowDevices/ConfigRowDevice.tsx | 36 ++++++ .../ConfigRowDevices/DiskDevice.test.tsx | 46 +++++++ .../ConfigRowDevices/DiskDevice.tsx | 24 ++++ .../ConfigRowDevices/VolumeDevice.test.tsx | 31 +++++ .../ConfigRowDevices/VolumeDevice.tsx | 21 ++++ .../LinodeConfigs/InterfaceListItem.tsx | 2 +- .../LinodeConfigs/utilities.test.ts | 36 +++++- .../LinodesDetail/LinodeConfigs/utilities.ts | 14 ++- .../manager/src/queries/volumes/events.ts | 17 ++- packages/queries/src/volumes/volumes.ts | 6 + .../src/helpers/createDevicesFromStrings.ts | 16 +-- .../helpers/createStringsFromDevices.test.ts | 8 +- 19 files changed, 341 insertions(+), 109 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12809-changed-1756996526265.md create mode 100644 packages/manager/.changeset/pr-12809-fixed-1756996376049.md create mode 100644 packages/manager/.changeset/pr-12809-fixed-1756996472770.md create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.tsx diff --git a/packages/api-v4/.changeset/pr-12809-changed-1756996526265.md b/packages/api-v4/.changeset/pr-12809-changed-1756996526265.md new file mode 100644 index 00000000000..84421741560 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12809-changed-1756996526265.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Update `DiskDevice` and `VolumeDevice` to more closely align with the API's behavior ([#12809](https://github.com/linode/manager/pull/12809)) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 114a7dce89d..a5b3592c2d1 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -382,10 +382,12 @@ export interface UpgradeInterfaceData { // ---------------------------------------------------------- export interface DiskDevice { - disk_id: null | number; + disk_id: number; + volume_id: null; } export interface VolumeDevice { - volume_id: null | number; + disk_id: null; + volume_id: number; } export type ConfigDevice = DiskDevice | null | VolumeDevice; diff --git a/packages/manager/.changeset/pr-12809-fixed-1756996376049.md b/packages/manager/.changeset/pr-12809-fixed-1756996376049.md new file mode 100644 index 00000000000..681e4a6a24b --- /dev/null +++ b/packages/manager/.changeset/pr-12809-fixed-1756996376049.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Linode configurations not updating after Volume is attached ([#12809](https://github.com/linode/manager/pull/12809)) diff --git a/packages/manager/.changeset/pr-12809-fixed-1756996472770.md b/packages/manager/.changeset/pr-12809-fixed-1756996472770.md new file mode 100644 index 00000000000..0652d80ab00 --- /dev/null +++ b/packages/manager/.changeset/pr-12809-fixed-1756996472770.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Linode configuration row incorrectly representing volume devices ([#12809](https://github.com/linode/manager/pull/12809)) diff --git a/packages/manager/src/features/Linodes/CloneLanding/utilities.ts b/packages/manager/src/features/Linodes/CloneLanding/utilities.ts index 30b02ababcc..d997a2cfd29 100644 --- a/packages/manager/src/features/Linodes/CloneLanding/utilities.ts +++ b/packages/manager/src/features/Linodes/CloneLanding/utilities.ts @@ -1,7 +1,7 @@ import produce from 'immer'; import { DateTime } from 'luxon'; -import { isDiskDevice } from '../LinodesDetail/LinodeConfigs/ConfigRow'; +import { isDiskDevice } from '../LinodesDetail/LinodeConfigs/utilities'; import type { Config, Devices, Disk } from '@linode/api-v4/lib/linodes'; import type { APIError } from '@linode/api-v4/lib/types'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx index 675312f5d26..57eee6cd228 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRow.tsx @@ -1,25 +1,15 @@ -import { - useAllLinodeDisksQuery, - useLinodeKernelQuery, - useLinodeQuery, - useLinodeVolumesQuery, -} from '@linode/queries'; -import { API_MAX_PAGE_SIZE } from '@linode/utilities'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; +import { useLinodeKernelQuery, useLinodeQuery } from '@linode/queries'; +import { List } from '@linode/ui'; +import React from 'react'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { ConfigRowDevice } from './ConfigRowDevices/ConfigRowDevice'; import { InterfaceListItem } from './InterfaceListItem'; import { ConfigActionMenu } from './LinodeConfigActionMenu'; -import type { - Config, - Devices, - DiskDevice, - VolumeDevice, -} from '@linode/api-v4/lib/linodes'; +import type { Config, Devices } from '@linode/api-v4'; interface Props { config: Config; @@ -29,18 +19,6 @@ interface Props { onEdit: () => void; } -export const isDiskDevice = ( - device: DiskDevice | VolumeDevice -): device is DiskDevice => { - return 'disk_id' in device; -}; - -const isVolumeDevice = ( - device: DiskDevice | VolumeDevice -): device is VolumeDevice => { - return 'volume_id' in device; -}; - export const ConfigRow = React.memo((props: Props) => { const { config, linodeId, onBoot, onDelete, onEdit } = props; @@ -48,63 +26,18 @@ export const ConfigRow = React.memo((props: Props) => { const { data: kernel } = useLinodeKernelQuery(config.kernel); - const { data: disks } = useAllLinodeDisksQuery(linodeId); - - const { data: volumes } = useLinodeVolumesQuery(linodeId, { - // This is not great, but lets us get all of the volumes for a Linode while keeping the store paginated. - // We can safely do this because linodes can't have more than 64 volumes. - page_size: API_MAX_PAGE_SIZE, - }); - const interfaces = config?.interfaces ?? []; - const validDevices = React.useMemo( - () => - Object.keys(config.devices) - .map((thisDevice: keyof Devices) => { - const device = config.devices[thisDevice]; - let label: null | string = null; - if (device && isDiskDevice(device)) { - label = - disks?.find((thisDisk) => thisDisk.id === device.disk_id) - ?.label ?? `disk-${device.disk_id}`; - } else if (device && isVolumeDevice(device)) { - label = - volumes?.data.find( - (thisVolume) => thisVolume.id === device.volume_id - )?.label ?? `volume-${device.volume_id}`; - } - - if (!label) { - return undefined; - } - return ( -
  • - /dev/{thisDevice} - {label} -
  • - ); - }) - .filter(Boolean), - [volumes, disks, config.devices] - ); - - const deviceLabels = React.useMemo( - () => {validDevices}, - [validDevices] - ); - const InterfaceList = ( - - {interfaces.map((interfaceEntry, idx) => { - return ( - - ); - })} - + li': { paddingY: 0.25 }, paddingY: 0.5 }}> + {interfaces.map((interfaceEntry, idx) => ( + + ))} + ); const defaultInterfaceLabel = 'eth0 – Public Internet'; @@ -114,7 +47,20 @@ export const ConfigRow = React.memo((props: Props) => { {config.label} – {kernel?.label ?? config.kernel} - {deviceLabels} + + li': { paddingY: 0.25 }, paddingY: 0.5 }}> + {Object.entries(config.devices).map( + ([deviceKey, device]: [keyof Devices, Devices[keyof Devices]]) => ( + + ) + )} + + {linode?.interface_generation !== 'linode' && ( {interfaces.length > 0 ? InterfaceList : defaultInterfaceLabel} @@ -133,11 +79,3 @@ export const ConfigRow = React.memo((props: Props) => { ); }); - -const StyledUl = styled('ul', { label: 'StyledUl' })(({ theme }) => ({ - listStyleType: 'none', - margin: 0, - paddingBottom: theme.spacing(), - paddingLeft: 0, - paddingTop: theme.spacing(), -})); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.test.tsx new file mode 100644 index 00000000000..a0d0ca38776 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ConfigRowDevice } from './ConfigRowDevice'; + +describe('ConfigRowDevice', () => { + it('renders "volume" when passed a volume device', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('/dev/sda – Volume 1')).toBeVisible(); + }); + + it('renders "Disk" when passed a disk device', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('/dev/sdb – Disk 2')).toBeVisible(); + }); + + it('renders "Unknown" if the device is malformed', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('/dev/sda – Unknown')).toBeVisible(); + }); + + it('renders nothing when the device itself is null', () => { + const { container } = renderWithTheme( + + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.tsx new file mode 100644 index 00000000000..748d43833e2 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/ConfigRowDevice.tsx @@ -0,0 +1,36 @@ +import { ListItem } from '@linode/ui'; +import React from 'react'; + +import { isDiskDevice, isVolumeDevice } from '../utilities'; +import { DiskDevice } from './DiskDevice'; +import { VolumeDevice } from './VolumeDevice'; + +import type { Devices } from '@linode/api-v4'; + +interface Props { + device: Devices[keyof Devices]; + deviceKey: keyof Devices; + linodeId: number; +} + +export const ConfigRowDevice = ({ device, deviceKey, linodeId }: Props) => { + if (!device) { + return null; + } + + if (isVolumeDevice(device)) { + return ; + } + + if (isDiskDevice(device)) { + return ( + + ); + } + + return ( + + /dev/{deviceKey} – Unknown + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.test.tsx new file mode 100644 index 00000000000..2e2d38baa46 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { linodeDiskFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DiskDevice } from './DiskDevice'; + +describe('DiskDevice', () => { + it("renders 'Disk ' by default", () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('/dev/sdb – Disk 2')).toBeVisible(); + }); + + it("renders the disk's label once the Linode's disks load", async () => { + const linodeId = 1; + const disks = [ + linodeDiskFactory.build({ id: 1, label: 'My Disk 1' }), + linodeDiskFactory.build({ id: 2, label: 'My Disk 2' }), + ]; + + server.use( + http.get(`*/v4*/linode/instances/${linodeId}/disks`, () => + HttpResponse.json(makeResourcePage(disks)) + ) + ); + + const { findByText } = renderWithTheme( + + ); + + expect(await findByText('/dev/sdb – My Disk 2')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.tsx new file mode 100644 index 00000000000..9bcd73bb155 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/DiskDevice.tsx @@ -0,0 +1,24 @@ +import { useAllLinodeDisksQuery } from '@linode/queries'; +import { ListItem } from '@linode/ui'; +import React from 'react'; + +import type { DiskDevice as DiskDeviceType } from '@linode/api-v4'; +import type { Devices } from '@linode/api-v4'; + +interface Props { + device: DiskDeviceType; + deviceKey: keyof Devices; + linodeId: number; +} + +export const DiskDevice = ({ linodeId, device, deviceKey }: Props) => { + const { data: disks } = useAllLinodeDisksQuery(linodeId); + + const disk = disks?.find((disk) => disk.id === device.disk_id); + + return ( + + /dev/{deviceKey} – {disk?.label ?? `Disk ${device.disk_id}`} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.test.tsx new file mode 100644 index 00000000000..2bedb3db73c --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { volumeFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { VolumeDevice } from './VolumeDevice'; + +describe('VolumeDevice', () => { + it("renders 'Volume ' by default", () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('/dev/sda – Volume 1')).toBeVisible(); + }); + + it("renders volume's label once the volume loads", async () => { + const volume = volumeFactory.build({ id: 5, label: 'my-attached-volume' }); + + server.use( + http.get(`*/v4*/volumes/${volume.id}`, () => HttpResponse.json(volume)) + ); + + const { findByText } = renderWithTheme( + + ); + + expect(await findByText('/dev/sdc – my-attached-volume')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.tsx new file mode 100644 index 00000000000..473a15d6baa --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/ConfigRowDevices/VolumeDevice.tsx @@ -0,0 +1,21 @@ +import { useVolumeQuery } from '@linode/queries'; +import { ListItem } from '@linode/ui'; +import React from 'react'; + +import type { VolumeDevice as VolumeDeviceType } from '@linode/api-v4'; +import type { Devices } from '@linode/api-v4'; + +interface Props { + device: VolumeDeviceType; + deviceKey: keyof Devices; +} + +export const VolumeDevice = ({ device, deviceKey }: Props) => { + const { data: volume } = useVolumeQuery(device.volume_id); + + return ( + + /dev/{deviceKey} – {volume?.label ?? `Volume ${device.volume_id}`} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx index 424c18e40c2..aaf2b425444 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/InterfaceListItem.tsx @@ -37,7 +37,7 @@ export const InterfaceListItem = (props: Props) => { }; return ( -
  • +
  • {interfaceName} – {getInterfaceLabel(interfaceEntry)}
  • ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts index b5d4ed998ee..29f2cc7039f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts @@ -1,6 +1,10 @@ import { linodeConfigInterfaceFactory } from '@linode/utilities'; -import { getPrimaryInterfaceIndex } from './utilities'; +import { + getPrimaryInterfaceIndex, + isDiskDevice, + isVolumeDevice, +} from './utilities'; describe('getPrimaryInterfaceIndex', () => { it('returns null if there are no interfaces', () => { @@ -34,3 +38,33 @@ describe('getPrimaryInterfaceIndex', () => { expect(getPrimaryInterfaceIndex(interfaces)).toBe(null); }); }); + +describe('isDiskDevice', () => { + it('returns true if the device defines a disk id', () => { + expect(isDiskDevice({ disk_id: 2, volume_id: null })).toBe(true); + }); + + it('returns false if the device does not define a disk id', () => { + expect(isDiskDevice({ disk_id: null, volume_id: 1 })).toBe(false); + }); + + it('returns false if the device is malformed', () => { + // @ts-expect-error testing an invalid device + expect(isDiskDevice({ disk_id: null, volume_id: null })).toBe(false); + }); +}); + +describe('isVolumeDevice', () => { + it('returns true if the device defines a volume id', () => { + expect(isVolumeDevice({ disk_id: null, volume_id: 1 })).toBe(true); + }); + + it('returns false if the device does not define a volume id', () => { + expect(isVolumeDevice({ disk_id: 5, volume_id: null })).toBe(false); + }); + + it('returns false if the device is malformed', () => { + // @ts-expect-error testing an invalid device + expect(isVolumeDevice({ disk_id: null, volume_id: null })).toBe(false); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts index 2b12640e578..5ca71db61ec 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts @@ -1,6 +1,6 @@ import { isEmpty } from '@linode/api-v4'; -import type { Interface } from '@linode/api-v4'; +import type { DiskDevice, Interface, VolumeDevice } from '@linode/api-v4'; /** * Gets the index of the primary Linode interface @@ -47,3 +47,15 @@ export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => { // As an example, this is the case when a Linode only has a VLAN interface. return null; }; + +export const isDiskDevice = ( + device: DiskDevice | VolumeDevice +): device is DiskDevice => { + return 'disk_id' in device && device.disk_id !== null; +}; + +export const isVolumeDevice = ( + device: DiskDevice | VolumeDevice +): device is VolumeDevice => { + return 'volume_id' in device && device.volume_id !== null; +}; diff --git a/packages/manager/src/queries/volumes/events.ts b/packages/manager/src/queries/volumes/events.ts index 9023c1a5112..d9f1b9834b8 100644 --- a/packages/manager/src/queries/volumes/events.ts +++ b/packages/manager/src/queries/volumes/events.ts @@ -1,4 +1,4 @@ -import { accountQueries, volumeQueries } from '@linode/queries'; +import { accountQueries, linodeQueries, volumeQueries } from '@linode/queries'; import type { EventHandlerData } from '@linode/queries'; @@ -16,11 +16,26 @@ export const volumeEventsHandler = ({ queryKey: volumeQueries.lists.queryKey, }); + // `event.entity` is the Volume if (event.entity) { invalidateQueries({ queryKey: volumeQueries.volume(event.entity.id).queryKey, }); } + + // `event.secondary_entity` will be a Linode when the event is a Volume attach / detach event + if (event.secondary_entity) { + // Invalidate the Linode's paginated list of volumes to ensure the list is up to date + invalidateQueries({ + queryKey: volumeQueries.linode(event.secondary_entity.id)._ctx.volumes + ._def, + }); + // Invalidate the Linode's configs because config storage devices may be updated when an attach/detach happens + invalidateQueries({ + queryKey: linodeQueries.linode(event.secondary_entity.id)._ctx.configs + .queryKey, + }); + } } if ( diff --git a/packages/queries/src/volumes/volumes.ts b/packages/queries/src/volumes/volumes.ts index 2b35ee3e4fb..057a129e7b8 100644 --- a/packages/queries/src/volumes/volumes.ts +++ b/packages/queries/src/volumes/volumes.ts @@ -22,6 +22,7 @@ import { import { accountQueries } from '../account'; import { queryPresets } from '../base'; +import { linodeQueries } from '../linodes'; import { profileQueries } from '../profile'; import { getAllVolumes, getAllVolumeTypes } from './requests'; @@ -200,6 +201,11 @@ export const useCreateVolumeMutation = () => { queryClient.invalidateQueries({ queryKey: volumeQueries.linode(volume.linode_id)._ctx.volumes._def, }); + // Invalidate the Linode's configs because the volume will now be returned as a Config device + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(volume.linode_id)._ctx.configs + .queryKey, + }); } // If a restricted user creates an entity, we must make sure grants are up to date. queryClient.invalidateQueries({ diff --git a/packages/utilities/src/helpers/createDevicesFromStrings.ts b/packages/utilities/src/helpers/createDevicesFromStrings.ts index c7dcb252a32..e1c5320147f 100644 --- a/packages/utilities/src/helpers/createDevicesFromStrings.ts +++ b/packages/utilities/src/helpers/createDevicesFromStrings.ts @@ -1,8 +1,4 @@ -import type { Devices } from '@linode/api-v4/lib/linodes'; - -type DiskRecord = Record<'disk_id', number>; - -type VolumeRecord = Record<'volume_id', number>; +import type { Devices, DiskDevice, VolumeDevice } from '@linode/api-v4'; /** * Maps the Devices type to have optional string values instead of device objects. @@ -23,8 +19,8 @@ export type DevicesAsStrings = StringTypeMap; */ const createTypeRecord = ( value?: string, -): DiskRecord | null | undefined | VolumeRecord => { - if (value === undefined || value === null || value === 'none') { +): DiskDevice | null | undefined | VolumeDevice => { + if (value === null || value === undefined || value === 'none') { return undefined; } @@ -38,7 +34,11 @@ const createTypeRecord = ( const key = `${type}_id` as const; // -> `volume_id` const idAsNumber = Number(id); // -> 123 - return { [key]: idAsNumber } as DiskRecord | VolumeRecord; // -> { volume_id: 123 } + if (key === 'volume_id') { + return { [key]: idAsNumber } as VolumeDevice; // -> { volume_id: 123 } + } + + return { [key]: idAsNumber } as DiskDevice; // -> { disk_id: 123 } }; export const createDevicesFromStrings = ( diff --git a/packages/utilities/src/helpers/createStringsFromDevices.test.ts b/packages/utilities/src/helpers/createStringsFromDevices.test.ts index 2b851ec87e6..c5cc9b7583a 100644 --- a/packages/utilities/src/helpers/createStringsFromDevices.test.ts +++ b/packages/utilities/src/helpers/createStringsFromDevices.test.ts @@ -25,9 +25,9 @@ describe('LinodeRescue', () => { sda: null, sdb: null, sdc: null, - sdd: { disk_id: 456 }, + sdd: { disk_id: 456, volume_id: null }, sde: null, - sdf: { disk_id: 123 }, + sdf: { disk_id: 123, volume_id: null }, sdg: null, sdh: null, }); @@ -39,10 +39,10 @@ describe('LinodeRescue', () => { it('should return IDs prepended by `volume-` for volumes', () => { const result = createStringsFromDevices({ sda: null, - sdb: { volume_id: 123 }, + sdb: { volume_id: 123, disk_id: null }, sdc: null, sdd: null, - sde: { volume_id: 456 }, + sde: { volume_id: 456, disk_id: null }, sdf: null, sdg: null, sdh: null, From adca23eabf21f615ddcdbe1b15745c12a5219f5a Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Fri, 5 Sep 2025 11:09:42 -0400 Subject: [PATCH 07/59] tests [M3-9610]: VM host maintenance banner in linode landing and details pages (#12753) * Add Host & VM Maintenance banner presence tests * Added changeset: Add tests for Host & VM Maintenance banner presence --------- Co-authored-by: Joe D'Amore --- .../pr-12753-tests-1757080227317.md | 5 + .../vm-host-maintenance-linode.spec.ts | 159 ++++++++++++++++++ .../LinodeMaintenanceBanner.tsx | 2 +- .../MaintenanceBanner/MaintenanceBannerV2.tsx | 4 +- 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-12753-tests-1757080227317.md create mode 100644 packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts diff --git a/packages/manager/.changeset/pr-12753-tests-1757080227317.md b/packages/manager/.changeset/pr-12753-tests-1757080227317.md new file mode 100644 index 00000000000..8091af0af66 --- /dev/null +++ b/packages/manager/.changeset/pr-12753-tests-1757080227317.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add tests for Host & VM Maintenance banner presence ([#12753](https://github.com/linode/manager/pull/12753)) diff --git a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts new file mode 100644 index 00000000000..47a0266d53f --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts @@ -0,0 +1,159 @@ +import { linodeFactory, profileFactory } from '@linode/utilities'; +import { mockGetMaintenance } from 'support/intercepts/account'; +import { mockGetNotifications } from 'support/intercepts/events'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinode, mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { accountMaintenanceFactory } from 'src/factories'; + +const mockProfile = profileFactory.build({ + timezone: 'America/New_York', +}); + +const mockLinodes = [ + linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }), + linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }), +]; + +const mockMaintenanceScheduled = accountMaintenanceFactory.build({ + entity: { + id: mockLinodes[0].id, + label: mockLinodes[0].label, + type: 'linode', + url: `/v4/linode/instances/${mockLinodes[0].id}`, + }, + type: 'reboot', + description: 'scheduled', + maintenance_policy_set: 'linode/power_off_on', + status: 'scheduled', + start_time: '2022-01-17T23:45:46.960', +}); + +const mockMaintenanceEmergency = accountMaintenanceFactory.build({ + entity: { + id: mockLinodes[1].id, + label: mockLinodes[1].label, + type: 'linode', + url: `/v4/linode/instances/${mockLinodes[1].id}`, + }, + type: 'cold_migration', + description: 'emergency', + maintenance_policy_set: 'linode/power_off_on', + status: 'scheduled', +}); + +describe('Host & VM maintenance notification banner', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + vmHostMaintenance: { + enabled: true, + }, + }).as('getFeatureFlags'); + + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetNotifications([]).as('getNotifications'); + mockGetProfile(mockProfile).as('getProfile'); + }); + + it('maintenance notification banner on landing page for 1 linode', function () { + mockGetMaintenance([mockMaintenanceScheduled], []).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + + cy.contains( + '1 Linode has upcoming scheduled maintenance. For more details, view Account Maintenance.' + ) + .should('be.visible') + .within(() => { + cy.findByText('Account Maintenance').click(); + cy.url().should('endWith', '/maintenance'); + }); + }); + + it('maintenance notification banner on landing page for >1 linodes', function () { + mockGetMaintenance( + [mockMaintenanceEmergency, mockMaintenanceScheduled], + [] + ).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + + cy.contains( + '2 Linodes have upcoming scheduled maintenance. For more details, view Account Maintenance.' + ) + .should('be.visible') + .within(() => { + cy.findByText('Account Maintenance').click(); + cy.url().should('endWith', '/maintenance'); + }); + }); + + it('banner present on details page when linode has pending maintenance', function () { + const mockLinode = mockLinodes[0]; + mockGetLinode(mockLinode.id, mockLinode).as('getLinode'); + mockGetMaintenance([mockMaintenanceScheduled], []).as('getMaintenances'); + + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.wait([ + '@getLinode', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + '@getProfile', + ]); + + cy.contains( + `Linode ${mockLinode.label} scheduled maintenance reboot will begin 01/17/2022 at 18:45. For more details, view Account Maintenance` + ) + .should('be.visible') + .within(() => { + cy.findByText('Account Maintenance').click(); + cy.url().should('endWith', '/maintenance'); + }); + }); + + it('maintenance notification banner not present on landing page if no linodes have pending maintenance', () => { + mockGetMaintenance([], []).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + cy.get('[data-qa-maintenance-banner-v2="true"]').should('not.exist'); + }); + + it('banner not present on details page if no pending maintenance', function () { + const mockLinode = mockLinodes[0]; + mockGetMaintenance([], []).as('getMaintenances'); + mockGetLinode(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + cy.wait([ + '@getLinode', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + cy.get('[data-qa-maintenance-banner="true"]').should('not.exist'); + }); +}); diff --git a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx index 49461185ee5..66133cbb8b9 100644 --- a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx @@ -34,7 +34,7 @@ export const LinodeMaintenanceBanner = ({ linodeId }: Props) => { if (!linodeMaintenance) return null; return ( - + Linode {linodeMaintenance.entity.label} {linodeMaintenance.description}{' '} maintenance {maintenanceTypeLabel} will begin{' '} diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx index ae4dafee713..10489ee0b0e 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.tsx @@ -31,8 +31,8 @@ export const MaintenanceBannerV2 = () => { ); return ( - maintenanceLinodes.size > 0 && ( - + maintenanceLinodes.size > 0 && ( + {pluralize('Linode', 'Linodes', maintenanceLinodes.size)} From 0083720b4495666f8cd58c79f63241155c42ce82 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:23:44 -0400 Subject: [PATCH 08/59] fix: [M3-10571] - Search Bar extra unnecessary encoding (#12808) * let TanStack router handle encoding/decoding * update and improve testing * Added changeset: Search bar performing extra encoding when an option is selected * improve code comment * assert URL in cypress test * fix cypress test --------- Co-authored-by: Banks Nussman --- .../pr-12808-fixed-1756929598765.md | 5 ++++ .../e2e/core/linodes/search-linodes.spec.ts | 26 ++++++++++++++----- .../features/TopMenu/SearchBar/SearchBar.tsx | 4 +-- 3 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-12808-fixed-1756929598765.md diff --git a/packages/manager/.changeset/pr-12808-fixed-1756929598765.md b/packages/manager/.changeset/pr-12808-fixed-1756929598765.md new file mode 100644 index 00000000000..357c7e65666 --- /dev/null +++ b/packages/manager/.changeset/pr-12808-fixed-1756929598765.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Search bar performing extra encoding when an option is selected ([#12808](https://github.com/linode/manager/pull/12808)) diff --git a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts index 265617b56e1..9946cf8dfde 100644 --- a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts @@ -17,14 +17,9 @@ describe('Search Linodes', () => { */ it('create a linode and make sure it shows up in the table and is searchable in main search tool', () => { cy.defer(() => - createTestLinode({ booted: true }, { waitForBoot: true }) + createTestLinode({ booted: false }, { waitForBoot: false }) ).then((linode: Linode) => { - cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.contains('Running').should('be.visible'); - }); + cy.visitWithLogin('/linodes?order=desc&orderBy=created'); // Confirm that linode is listed on the landing page. cy.findByText(linode.label).should('be.visible'); @@ -39,7 +34,24 @@ describe('Search Linodes', () => { // Use the main search bar to search and filter linode by id: pattern ui.mainSearch.find().clear().type(`id:${linode.id}`); + + // Verify the Linode shows as an option in the main search autocomplete ui.autocompletePopper.findByTitle(linode.label).should('be.visible'); + + // Press Enter to go to the search page + cy.focused().type('{enter}'); + + // Verify the search field still has the correct search query that the user typed + ui.mainSearch + .find() + .findByRole('combobox') + .should('have.value', `id:${linode.id}`); + + // Verify we land on the search page + cy.url().should('endWith', `/search?query=id%3A${linode.id}`); + cy.findByText(`Search Results for "id:${linode.id}"`).should( + 'be.visible' + ); }); }); }); diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index c2fac44cd77..afeddd1446b 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -117,7 +117,7 @@ export const SearchBar = () => { navigate({ to: `/search`, search: { - query: encodeURIComponent(searchText), + query: searchText, }, }); handleClose(); @@ -138,7 +138,7 @@ export const SearchBar = () => { navigate({ to: `/search`, search: { - query: encodeURIComponent(searchText), + query: searchText, }, }); } From 849551ac87d0185338cdbe0ce531ddb813ca864c Mon Sep 17 00:00:00 2001 From: bill-akamai Date: Fri, 5 Sep 2025 11:07:33 -0500 Subject: [PATCH 09/59] fix:[M3-10335] - useIsPageScrollable hook is not working correctly for slightly taller pages (#12695) * Create debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Update debug-M3-9877.yml * Make fix * Remove debug .yml file * Revert to old method and use footer height calculation instead * Added changeset: Include footer height in useIsPageScrollable calculation --- packages/manager/.changeset/pr-12695-fixed-1756931382058.md | 5 +++++ packages/manager/src/components/PrimaryNav/utils.ts | 5 ++++- packages/manager/src/features/Footer.tsx | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12695-fixed-1756931382058.md diff --git a/packages/manager/.changeset/pr-12695-fixed-1756931382058.md b/packages/manager/.changeset/pr-12695-fixed-1756931382058.md new file mode 100644 index 00000000000..4a364b05b86 --- /dev/null +++ b/packages/manager/.changeset/pr-12695-fixed-1756931382058.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Include footer height in useIsPageScrollable calculation ([#12695](https://github.com/linode/manager/pull/12695)) diff --git a/packages/manager/src/components/PrimaryNav/utils.ts b/packages/manager/src/components/PrimaryNav/utils.ts index 2efa0bd157e..304716a4ecb 100644 --- a/packages/manager/src/components/PrimaryNav/utils.ts +++ b/packages/manager/src/components/PrimaryNav/utils.ts @@ -1,5 +1,6 @@ import React from 'react'; +import { FOOTER_HEIGHT } from 'src/features/Footer'; import { TOPMENU_HEIGHT } from 'src/features/TopMenu/constants'; import type { LinkProps } from '@tanstack/react-router'; @@ -41,7 +42,9 @@ export const useIsPageScrollable = ( const contentHeight = contentRef.current.scrollHeight; const viewportHeight = document.documentElement.clientHeight; - setIsPageScrollable(contentHeight + TOPMENU_HEIGHT > viewportHeight); + setIsPageScrollable( + contentHeight + TOPMENU_HEIGHT + FOOTER_HEIGHT > viewportHeight + ); }, [contentRef]); React.useEffect(() => { diff --git a/packages/manager/src/features/Footer.tsx b/packages/manager/src/features/Footer.tsx index c439b665d64..a501ca9a677 100644 --- a/packages/manager/src/features/Footer.tsx +++ b/packages/manager/src/features/Footer.tsx @@ -7,6 +7,8 @@ import { DEVELOPERS_LINK, FEEDBACK_LINK } from 'src/constants'; import packageJson from '../../package.json'; +export const FOOTER_HEIGHT = 45; + export const Footer = React.memo(() => { return (