@@ -228,16 +236,26 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => {
/>
)}
-
+
Database name
-
+
{isLegacy ? database.engine : 'defaultdb'}
-
+
Host
-
+
{database.hosts?.primary ? (
<>
{database.hosts?.primary}
@@ -262,24 +280,39 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => {
)}
-
+
{isLegacy ? 'Private Network Host' : 'Read-only Host'}
-
+
{readOnlyHost()}
-
+
Port
-
+
{database.port}
-
+
SSL
-
+
{database.ssl_connection ? 'ENABLED' : 'DISABLED'}
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx
index 4eb44b18f54..bb639ecc62f 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx
@@ -1,4 +1,5 @@
import { Box, TooltipIcon, Typography } from '@linode/ui';
+import { formatStorageUnits } from '@linode/utilities';
import * as React from 'react';
import { makeStyles } from 'tss-react/mui';
@@ -7,7 +8,6 @@ import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVers
import { useDatabaseTypesQuery } from 'src/queries/databases/databases';
import { useInProgressEvents } from 'src/queries/events/events';
import { useRegionsQuery } from 'src/queries/regions/regions';
-import { formatStorageUnits } from 'src/utilities/formatStorageUnits';
import { convertMegabytesTo } from 'src/utilities/unitConversions';
import type { Region } from '@linode/api-v4';
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx
index a0bca4e196f..af6add09add 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx
@@ -6,6 +6,7 @@ import {
TooltipIcon,
Typography,
} from '@linode/ui';
+import { downloadFile } from '@linode/utilities';
import { useTheme } from '@mui/material';
import { useSnackbar } from 'notistack';
import * as React from 'react';
@@ -15,7 +16,6 @@ import DownloadIcon from 'src/assets/icons/lke-download.svg';
import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
import { DB_ROOT_USERNAME } from 'src/constants';
import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases';
-import { downloadFile } from 'src/utilities/downloadFile';
import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types';
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx
index f81eb723e5d..e1bdd168ece 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx
@@ -1,11 +1,10 @@
-import { CircleProgress, Notice } from '@linode/ui';
+import { CircleProgress, ErrorState, Notice } from '@linode/ui';
import { createLazyRoute } from '@tanstack/react-router';
import * as React from 'react';
import { matchPath, useHistory, useParams } from 'react-router-dom';
import { BetaChip } from 'src/components/BetaChip/BetaChip';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
-import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { LandingHeader } from 'src/components/LandingHeader';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { TabLinkList } from 'src/components/Tabs/TabLinkList';
@@ -22,6 +21,8 @@ import {
} from 'src/queries/databases/databases';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import { DatabaseAdvancedConfiguration } from './DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration';
+
import type { Engine } from '@linode/api-v4/lib/databases/types';
import type { APIError } from '@linode/api-v4/lib/types';
import type { Tab } from 'src/components/Tabs/TabLinkList';
@@ -91,6 +92,7 @@ export const DatabaseDetail = () => {
const isDefault = database.platform === 'rdbms-default';
const isMonitorEnabled = isDefault && flags.dbaasV2MonitorMetrics?.enabled;
+ const isAdvancedConfigEnabled = isDefault && flags.databaseAdvancedConfig;
const tabs: Tab[] = [
{
@@ -109,6 +111,7 @@ export const DatabaseDetail = () => {
const resizeIndex = isMonitorEnabled ? 3 : 2;
const backupsIndex = isMonitorEnabled ? 2 : 1;
+ const settingsIndex = isMonitorEnabled ? 4 : 3;
if (isMonitorEnabled) {
tabs.splice(1, 0, {
@@ -125,6 +128,13 @@ export const DatabaseDetail = () => {
});
}
+ if (isAdvancedConfigEnabled) {
+ tabs.splice(5, 0, {
+ routeName: `/databases/${engine}/${id}/configs`,
+ title: 'Advanced Configuration',
+ });
+ }
+
const getTabIndex = () => {
const tabChoice = tabs.findIndex((tab) =>
Boolean(matchPath(tab.routeName, { path: location.pathname }))
@@ -226,12 +236,17 @@ export const DatabaseDetail = () => {
/>
) : null}
-
+
+ {isAdvancedConfigEnabled && (
+
+
+
+ )}
{isDefault && }
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx
index a7230c71e9f..1fec33de820 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx
@@ -1,3 +1,4 @@
+import { capitalize } from '@linode/utilities';
import { screen, within } from '@testing-library/react';
import { fireEvent } from '@testing-library/react';
import { waitForElementToBeRemoved } from '@testing-library/react';
@@ -9,7 +10,6 @@ import DatabaseLanding from 'src/features/Databases/DatabaseLanding/DatabaseLand
import DatabaseRow from 'src/features/Databases/DatabaseLanding/DatabaseRow';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { HttpResponse, http, server } from 'src/mocks/testServer';
-import { capitalize } from 'src/utilities/capitalize';
import { formatDate } from 'src/utilities/formatDate';
import {
mockMatchMedia,
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx
index 0b62810b54b..e7fb07cc9a4 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx
@@ -1,10 +1,9 @@
-import { CircleProgress } from '@linode/ui';
+import { CircleProgress, ErrorState } from '@linode/ui';
import { Box } from '@mui/material';
import { createLazyRoute } from '@tanstack/react-router';
import * as React from 'react';
import { useHistory } from 'react-router-dom';
-import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { LandingHeader } from 'src/components/LandingHeader';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { Tab } from 'src/components/Tabs/Tab';
@@ -148,9 +147,9 @@ const DatabaseLanding = () => {
);
};
@@ -158,13 +157,13 @@ const DatabaseLanding = () => {
const defaultTable = () => {
return (
);
};
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx
index 177df043eae..abfb789168b 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx
@@ -1,4 +1,5 @@
import { Chip } from '@linode/ui';
+import { formatStorageUnits } from '@linode/utilities';
import * as React from 'react';
import { Hidden } from 'src/components/Hidden';
@@ -14,7 +15,6 @@ import { useProfile } from 'src/queries/profile/profile';
import { useRegionsQuery } from 'src/queries/regions/regions';
import { isWithinDays, parseAPIDate } from 'src/utilities/date';
import { formatDate } from 'src/utilities/formatDate';
-import { formatStorageUnits } from 'src/utilities/formatStorageUnits';
import type { Event } from '@linode/api-v4';
import type {
diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts
index 1c1ce93f2e7..2946366fdd0 100644
--- a/packages/manager/src/features/Databases/utilities.test.ts
+++ b/packages/manager/src/features/Databases/utilities.test.ts
@@ -7,6 +7,7 @@ import {
databaseTypeFactory,
} from 'src/factories';
import {
+ formatConfigValue,
getDatabasesDescription,
hasPendingUpdates,
isDateOutsideBackup,
@@ -559,3 +560,25 @@ describe('upgradableVersions', () => {
expect(result).toBeUndefined();
});
});
+
+describe('formatConfigValue', () => {
+ it('should return "Enabled" when configValue is "true"', () => {
+ const result = formatConfigValue('true');
+ expect(result).toBe('Enabled');
+ });
+
+ it('should return "Disabled" when configValue is "false"', () => {
+ const result = formatConfigValue('false');
+ expect(result).toBe('Disabled');
+ });
+
+ it('should return " -" when configValue is "undefined"', () => {
+ const result = formatConfigValue('undefined');
+ expect(result).toBe(' - ');
+ });
+
+ it('should return the original configValue for other values', () => {
+ const result = formatConfigValue('+03:00');
+ expect(result).toBe('+03:00');
+ });
+});
diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts
index 76e27d0530e..50643de2d08 100644
--- a/packages/manager/src/features/Databases/utilities.ts
+++ b/packages/manager/src/features/Databases/utilities.ts
@@ -245,3 +245,22 @@ export const upgradableVersions = (
version: string,
engines?: Pick[]
) => engines?.filter((e) => e.engine === engine && e.version > version);
+
+/**
+ * Formats the provided config value into a more user-friendly representation.
+ * - If the value is 'true', it will be displayed as 'Enabled'.
+ * - If the value is 'false', it will be displayed as 'Disabled'.
+ * - If the value is 'undefined', it will be displayed as ' - '.
+ * - Otherwise, the original value will be returned as-is.
+ *
+ * @param {string} configValue - The configuration value to be formatted.
+ * @returns {string} - The formatted string based on the configValue.
+ */
+export const formatConfigValue = (configValue: string) =>
+ configValue === 'true'
+ ? 'Enabled'
+ : configValue === 'false'
+ ? 'Disabled'
+ : configValue === 'undefined'
+ ? ' - '
+ : configValue;
diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx
index bc7691d0049..708ec79f305 100644
--- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx
+++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx
@@ -10,7 +10,7 @@ import {
} from '@linode/ui';
import { createDomainSchema } from '@linode/validation/lib/domains.schema';
import { styled } from '@mui/material/styles';
-import Grid from '@mui/material/Unstable_Grid2';
+import Grid from '@mui/material/Grid2';
import { useNavigate } from '@tanstack/react-router';
import { useFormik } from 'formik';
import * as React from 'react';
diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx
index b357736aa2f..16f6bd05d1c 100644
--- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx
+++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx
@@ -1,10 +1,15 @@
-import { CircleProgress, Notice, Paper, Typography } from '@linode/ui';
+import {
+ CircleProgress,
+ ErrorState,
+ Notice,
+ Paper,
+ Typography,
+} from '@linode/ui';
import { styled } from '@mui/material/styles';
-import Grid from '@mui/material/Unstable_Grid2';
+import Grid from '@mui/material/Grid2';
import { useLocation, useNavigate, useParams } from '@tanstack/react-router';
import * as React from 'react';
-import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { LandingHeader } from 'src/components/LandingHeader';
import { TagCell } from 'src/components/TagCell/TagCell';
import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted';
@@ -118,7 +123,7 @@ export const DomainDetail = () => {
)}
-
+
{
updateRecords={refetchRecords}
/>
-
+
Tags
diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts
index 3ee8534e082..0f077bed392 100644
--- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts
+++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts
@@ -1,5 +1,5 @@
import { styled } from '@mui/material/styles';
-import Grid from '@mui/material/Unstable_Grid2';
+import Grid from '@mui/material/Grid2';
import { TableCell } from 'src/components/TableCell';
diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx
index eb5e4b9d48f..6971a568175 100644
--- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx
+++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx
@@ -1,6 +1,6 @@
import { deleteDomainRecord as _deleteDomainRecord } from '@linode/api-v4/lib/domains';
import { Typography } from '@linode/ui';
-import Grid from '@mui/material/Unstable_Grid2';
+import Grid from '@mui/material/Grid2';
import * as React from 'react';
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
diff --git a/packages/manager/src/features/Domains/DomainDetail/index.tsx b/packages/manager/src/features/Domains/DomainDetail/index.tsx
index 6eaa1b60106..10f5274d43e 100644
--- a/packages/manager/src/features/Domains/DomainDetail/index.tsx
+++ b/packages/manager/src/features/Domains/DomainDetail/index.tsx
@@ -1,8 +1,7 @@
-import { CircleProgress } from '@linode/ui';
+import { CircleProgress, ErrorState } from '@linode/ui';
import { useParams } from '@tanstack/react-router';
import * as React from 'react';
-import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { NotFound } from 'src/components/NotFound';
import { useDomainQuery } from 'src/queries/domains';
diff --git a/packages/manager/src/features/Domains/DomainsLanding.tsx b/packages/manager/src/features/Domains/DomainsLanding.tsx
index 81e8ac001b5..43a8ca06e61 100644
--- a/packages/manager/src/features/Domains/DomainsLanding.tsx
+++ b/packages/manager/src/features/Domains/DomainsLanding.tsx
@@ -1,4 +1,4 @@
-import { Button, CircleProgress, Notice } from '@linode/ui';
+import { Button, CircleProgress, ErrorState, Notice } from '@linode/ui';
import { styled } from '@mui/material/styles';
import { useLocation, useNavigate, useParams } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
@@ -6,7 +6,6 @@ import * as React from 'react';
import { DeletionDialog } from 'src/components/DeletionDialog/DeletionDialog';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
-import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { Hidden } from 'src/components/Hidden';
import { LandingHeader } from 'src/components/LandingHeader';
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
diff --git a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx
index 9e14564d3aa..997c0228a5e 100644
--- a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx
+++ b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx
@@ -1,7 +1,7 @@
+import { downloadFile } from '@linode/utilities';
import { fireEvent, waitFor } from '@testing-library/react';
import * as React from 'react';
-import { downloadFile } from 'src/utilities/downloadFile';
import { renderWithThemeAndRouter } from 'src/utilities/testHelpers';
import { DownloadDNSZoneFileButton } from './DownloadDNSZoneFileButton';
@@ -18,8 +18,8 @@ vi.mock('@linode/api-v4/lib/domains', async () => {
};
});
-vi.mock('src/utilities/downloadFile', async () => {
- const actual = await vi.importActual('src/utilities/downloadFile');
+vi.mock('@linode/utilities', async () => {
+ const actual = await vi.importActual('@linode/utilities');
return {
...actual,
downloadFile: vi.fn(),
diff --git a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx
index 25ec08bea09..68771f5c106 100644
--- a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx
+++ b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx
@@ -1,9 +1,8 @@
import { getDNSZoneFile } from '@linode/api-v4/lib/domains';
import { Button } from '@linode/ui';
+import { downloadFile } from '@linode/utilities';
import * as React from 'react';
-import { downloadFile } from 'src/utilities/downloadFile';
-
type DownloadDNSZoneFileButtonProps = {
domainId: number;
domainLabel: string;
diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts
index aea30496629..8c27bf404ee 100644
--- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts
+++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts
@@ -1,6 +1,6 @@
import { Notice } from '@linode/ui';
import { styled } from '@mui/material/styles';
-import Grid from '@mui/material/Unstable_Grid2';
+import Grid from '@mui/material/Grid2';
export const StyledNotice = styled(Notice, {
label: 'StyledNotice',
diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx
index 8801a7e6f63..4302bb60a99 100644
--- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx
+++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx
@@ -1,4 +1,4 @@
-import Grid from '@mui/material/Unstable_Grid2';
+import Grid from '@mui/material/Grid2';
import { useQueryClient } from '@tanstack/react-query';
import { createLazyRoute } from '@tanstack/react-router';
import { curry } from 'ramda';
@@ -101,7 +101,13 @@ export const EntityTransfersCreate = () => {
/>
) : null}
-
+
{
selectedLinodes={state.linodes}
/>
-
+
handleCreateTransfer(payload, queryClient)
diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx
index 9e69baf3102..049db5dbeb6 100644
--- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx
+++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx
@@ -1,11 +1,11 @@
import { acceptEntityTransfer } from '@linode/api-v4/lib/entity-transfers';
-import { Checkbox, CircleProgress, Notice } from '@linode/ui';
+import { Checkbox, CircleProgress, ErrorState, Notice } from '@linode/ui';
+import { capitalize } from '@linode/utilities';
import { useQueryClient } from '@tanstack/react-query';
import { useSnackbar } from 'notistack';
import * as React from 'react';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
-import { ErrorState } from 'src/components/ErrorState/ErrorState';
import {
TRANSFER_FILTERS,
queryKey,
@@ -13,7 +13,6 @@ import {
} from 'src/queries/entityTransfers';
import { useProfile } from 'src/queries/profile/profile';
import { sendEntityTransferReceiveEvent } from 'src/utilities/analytics/customEventAnalytics';
-import { capitalize } from 'src/utilities/capitalize';
import { parseAPIDate } from 'src/utilities/date';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import { formatDate } from 'src/utilities/formatDate';
diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts
index acb35d9ca1e..d84133d7a47 100644
--- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts
+++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts
@@ -1,6 +1,6 @@
import { Button, TextField, Typography } from '@linode/ui';
import { styled } from '@mui/material/styles';
-import Grid from '@mui/material/Unstable_Grid2';
+import Grid from '@mui/material/Grid2';
// sm = 600, md = 960, lg = 1280
diff --git a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx
index b34d45e7ae1..9a6e5eb7fe8 100644
--- a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx
+++ b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx
@@ -1,11 +1,11 @@
import { StyledLinkButton } from '@linode/ui';
+import { capitalize } from '@linode/utilities';
import * as React from 'react';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
import { Hidden } from 'src/components/Hidden';
import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { TableCell } from 'src/components/TableCell';
-import { capitalize } from 'src/utilities/capitalize';
import { pluralize } from 'src/utilities/pluralize';
import {
diff --git a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx
index fdec838e49e..0e2fbbeaa04 100644
--- a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx
+++ b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx
@@ -1,4 +1,5 @@
import { Accordion } from '@linode/ui';
+import { capitalize } from '@linode/utilities';
import * as React from 'react';
import { Hidden } from 'src/components/Hidden';
@@ -8,7 +9,6 @@ import { TableCell } from 'src/components/TableCell';
import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper';
import { TableHead } from 'src/components/TableHead';
import { TableRow } from 'src/components/TableRow';
-import { capitalize } from 'src/utilities/capitalize';
import { ConfirmTransferCancelDialog } from './EntityTransfersLanding/ConfirmTransferCancelDialog';
import { TransferDetailsDialog } from './EntityTransfersLanding/TransferDetailsDialog';
diff --git a/packages/manager/src/features/EntityTransfers/utilities.ts b/packages/manager/src/features/EntityTransfers/utilities.ts
index 50ea6e3f71f..89620df66ce 100644
--- a/packages/manager/src/features/EntityTransfers/utilities.ts
+++ b/packages/manager/src/features/EntityTransfers/utilities.ts
@@ -1,6 +1,5 @@
import { TransferEntities } from '@linode/api-v4/lib/entity-transfers';
-
-import { capitalize } from 'src/utilities/capitalize';
+import { capitalize } from '@linode/utilities';
// Return the count of each transferred entity by type, for reporting analytics.
// E.g. { linodes: [ 1234 ], domains: [ 2345, 3456 ]} -> "Linodes: 1, Domains: 2"
diff --git a/packages/manager/src/features/Events/asyncToasts.tsx b/packages/manager/src/features/Events/asyncToasts.tsx
index 5c3cdc9553e..93de10bf20a 100644
--- a/packages/manager/src/features/Events/asyncToasts.tsx
+++ b/packages/manager/src/features/Events/asyncToasts.tsx
@@ -80,6 +80,7 @@ export const toasts: Toasts = {
linode_clone: createToast({ failure: true, success: true }),
linode_migrate: createToast({ failure: true, success: true }),
linode_migrate_datacenter: createToast({ failure: true, success: true }),
+ linode_rebuild: createToast({ failure: true, success: true }),
linode_resize: createToast({ failure: true, success: true }),
linode_snapshot: createToast({ failure: { persist: true } }),
longviewclient_create: createToast({ failure: true, success: true }),
diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx
index c9db016c1e5..140dd563b4d 100644
--- a/packages/manager/src/features/Events/factories/firewall.tsx
+++ b/packages/manager/src/features/Events/factories/firewall.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
-import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding';
+import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/constants';
import { EventLink } from '../EventLink';
diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx
index 2e32e5dfff4..e2ce2d7329a 100644
--- a/packages/manager/src/features/Events/factories/linode.tsx
+++ b/packages/manager/src/features/Events/factories/linode.tsx
@@ -1,9 +1,9 @@
+import { formatStorageUnits } from '@linode/utilities';
import * as React from 'react';
import { Link } from 'src/components/Link';
import { useLinodeQuery } from 'src/queries/linodes/linodes';
import { useTypeQuery } from 'src/queries/types';
-import { formatStorageUnits } from 'src/utilities/formatStorageUnits';
import { EventLink } from '../EventLink';
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx
index 2e14b866959..b45d1cc0291 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx
@@ -12,7 +12,24 @@ const props = {
onClose,
open: true,
};
+
+const queryMocks = vi.hoisted(() => ({
+ useParams: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
describe('AddLinodeDrawer', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({ id: '1' });
+ });
+
it('should contain helper text', () => {
const { getByText } = renderWithTheme();
expect(getByText(helperText)).toBeInTheDocument();
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
index ba5f2a15afb..99607a9e36b 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
@@ -1,8 +1,8 @@
import { Notice } from '@linode/ui';
import { useTheme } from '@mui/material';
+import { useParams } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import * as React from 'react';
-import { useParams } from 'react-router-dom';
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
import { Drawer } from 'src/components/Drawer';
@@ -29,7 +29,7 @@ interface Props {
export const AddLinodeDrawer = (props: Props) => {
const { helperText, onClose, open } = props;
- const { id } = useParams<{ id: string }>();
+ const { id } = useParams({ strict: false });
const { enqueueSnackbar } = useSnackbar();
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx
index 36d229be748..c537eaa1497 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx
@@ -13,7 +13,23 @@ const props = {
open: true,
};
+const queryMocks = vi.hoisted(() => ({
+ useParams: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useParams: queryMocks.useParams,
+ };
+});
+
describe('AddNodeBalancerDrawer', () => {
+ beforeEach(() => {
+ queryMocks.useParams.mockReturnValue({ id: '1' });
+ });
+
it('should contain helper text', () => {
const { getByText } = renderWithTheme();
expect(getByText(helperText)).toBeInTheDocument();
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
index 435fd687795..bb1b0df38b6 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
@@ -1,8 +1,8 @@
import { Notice } from '@linode/ui';
import { useTheme } from '@mui/material';
+import { useParams } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import * as React from 'react';
-import { useParams } from 'react-router-dom';
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
import { Drawer } from 'src/components/Drawer';
@@ -30,7 +30,7 @@ interface Props {
export const AddNodebalancerDrawer = (props: Props) => {
const { helperText, onClose, open } = props;
const { enqueueSnackbar } = useSnackbar();
- const { id } = useParams<{ id: string }>();
+ const { id } = useParams({ strict: false });
const { data: grants } = useGrants();
const { data: profile } = useProfile();
const isRestrictedUser = Boolean(profile?.restricted);
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx
index ff489aae8f8..bd80e4a2859 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx
@@ -3,29 +3,26 @@ import * as React from 'react';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
export interface ActionHandlers {
- triggerRemoveDevice: (deviceID: number, label: string) => void;
+ handleRemoveDevice: (device: FirewallDevice) => void;
}
-import type { FirewallDeviceEntityType } from '@linode/api-v4';
+import type { FirewallDevice } from '@linode/api-v4';
export interface FirewallDeviceActionMenuProps extends ActionHandlers {
- deviceEntityID: number;
- deviceID: number;
- deviceLabel: string;
- deviceType: FirewallDeviceEntityType;
+ device: FirewallDevice;
disabled: boolean;
}
export const FirewallDeviceActionMenu = React.memo(
(props: FirewallDeviceActionMenuProps) => {
- const { deviceID, deviceLabel, disabled, triggerRemoveDevice } = props;
+ const { device, disabled, handleRemoveDevice } = props;
return (
triggerRemoveDevice(deviceID, deviceLabel)}
+ onClick={() => handleRemoveDevice(device)}
/>
);
}
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx
index 102a24c7cb5..5ce6a4024d0 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx
@@ -1,19 +1,47 @@
-import { fireEvent } from '@testing-library/react';
-import { createMemoryHistory } from 'history';
+import { fireEvent, waitFor } from '@testing-library/react';
import * as React from 'react';
-import { Router } from 'react-router-dom';
import { firewallDeviceFactory } from 'src/factories';
-import { http, HttpResponse, server } from 'src/mocks/testServer';
-import { renderWithTheme } from 'src/utilities/testHelpers';
-
+import { HttpResponse, http, server } from 'src/mocks/testServer';
import {
- FirewallDeviceLanding,
- FirewallDeviceLandingProps,
-} from './FirewallDeviceLanding';
+ renderWithTheme,
+ renderWithThemeAndRouter,
+} from 'src/utilities/testHelpers';
+
+import { FirewallDeviceLanding } from './FirewallDeviceLanding';
+import type { FirewallDeviceLandingProps } from './FirewallDeviceLanding';
import type { FirewallDeviceEntityType } from '@linode/api-v4';
+const queryMocks = vi.hoisted(() => ({
+ useLocation: vi.fn().mockReturnValue({}),
+ useNavigate: vi.fn(() => vi.fn()),
+ useOrderV2: vi.fn().mockReturnValue({
+ handleOrderChange: vi.fn(),
+ }),
+ useParams: vi.fn().mockReturnValue({}),
+ useSearch: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useLocation: queryMocks.useLocation,
+ useNavigate: queryMocks.useNavigate,
+ useParams: queryMocks.useParams,
+ useSearch: queryMocks.useSearch,
+ };
+});
+
+vi.mock('src/hooks/useOrderV2', async () => {
+ const actual = await vi.importActual('src/hooks/useOrderV2');
+ return {
+ ...actual,
+ useOrderV2: queryMocks.useOrderV2,
+ };
+});
+
const baseProps = (
type: FirewallDeviceEntityType
): FirewallDeviceLandingProps => ({
@@ -34,6 +62,14 @@ services.forEach((service: FirewallDeviceEntityType) => {
const serviceName = service === 'linode' ? 'Linode' : 'NodeBalancer';
describe(`Firewall ${serviceName} landing page`, () => {
+ beforeEach(() => {
+ queryMocks.useLocation.mockReturnValue({
+ pathname: '/firewalls/1/linodes',
+ });
+ queryMocks.useParams.mockReturnValue({
+ id: '1',
+ });
+ });
const props = [baseProps(service), disabledProps(service)];
props.forEach((prop) => {
@@ -62,6 +98,7 @@ services.forEach((service: FirewallDeviceEntityType) => {
expect(addButton).toHaveAttribute('aria-disabled', 'true');
});
+
it('should contain permission notice when disabled', () => {
const { getByRole } = renderWithTheme(
@@ -80,17 +117,26 @@ services.forEach((service: FirewallDeviceEntityType) => {
expect(addButton).toHaveAttribute('aria-disabled', 'false');
});
- it(`should navigate to Add ${serviceName} To Firewall drawer when enabled`, () => {
- const history = createMemoryHistory();
- const { getByTestId } = renderWithTheme(
-
-
-
+
+ it(`should navigate to Add ${serviceName} To Firewall drawer when enabled`, async () => {
+ const mockNavigate = vi.fn();
+ queryMocks.useNavigate.mockReturnValue(mockNavigate);
+
+ const { getByTestId } = await renderWithThemeAndRouter(
+ ,
+ {
+ initialRoute: `/firewalls/1/${service}`,
+ }
);
const addButton = getByTestId('add-device-button');
fireEvent.click(addButton);
- const baseUrl = '/';
- expect(history.location.pathname).toBe(baseUrl + '/add');
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith({
+ params: { id: '1' },
+ to: `/firewalls/$id/${service}s/add`,
+ });
+ });
});
}
});
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx
index 7b4dd00f56c..60f9ffa7c71 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx
@@ -1,15 +1,15 @@
import { Button, Notice, Typography } from '@linode/ui';
-import { useTheme } from '@mui/material/styles';
+import Grid from '@mui/material/Grid2';
import { styled } from '@mui/material/styles';
-import Grid from '@mui/material/Unstable_Grid2';
+import { useTheme } from '@mui/material/styles';
+import { useLocation, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
-import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
-import { useAllFirewallDevicesQuery } from 'src/queries/firewalls';
import { AddLinodeDrawer } from './AddLinodeDrawer';
import { AddNodebalancerDrawer } from './AddNodebalancerDrawer';
+import { formattedTypes } from './constants';
import { FirewallDeviceTable } from './FirewallDeviceTable';
import { RemoveDeviceDialog } from './RemoveDeviceDialog';
@@ -22,83 +22,60 @@ export interface FirewallDeviceLandingProps {
type: FirewallDeviceEntityType;
}
-export const formattedTypes: Record = {
- interface: 'Interface', // @TODO Linode Interface: double check this when working on UI tickets
- linode: 'Linode',
- nodebalancer: 'NodeBalancer',
-};
-
export const FirewallDeviceLanding = React.memo(
(props: FirewallDeviceLandingProps) => {
const { disabled, firewallId, firewallLabel, type } = props;
-
- const { data: allDevices, error, isLoading } = useAllFirewallDevicesQuery(
- firewallId
- );
-
const theme = useTheme();
-
- const history = useHistory();
- const routeMatch = useRouteMatch();
+ const navigate = useNavigate();
const location = useLocation();
-
const helperText =
'Assign one or more services to this firewall. You can add services later if you want to customize your rules first.';
- React.useEffect(() => {
- if (location.pathname.endsWith('add')) {
- setDeviceDrawerOpen(true);
- }
- }, [location.pathname]);
-
- const devices =
- allDevices?.filter((device) => device.entity.type === type) || [];
-
- const [filteredDevices, setFilteredDevices] = React.useState<
- FirewallDevice[]
- >([]);
-
- React.useEffect(() => {
- setFilteredDevices(devices);
- }, [allDevices]);
-
- const [
- isRemoveDeviceDialogOpen,
- setIsRemoveDeviceDialogOpen,
- ] = React.useState(false);
-
- const [selectedDeviceId, setSelectedDeviceId] = React.useState(-1);
-
- const selectedDevice = filteredDevices?.find(
- (device) => device.id === selectedDeviceId
- );
-
- const [addDeviceDrawerOpen, setDeviceDrawerOpen] = React.useState(
- false
- );
-
const handleClose = () => {
- setDeviceDrawerOpen(false);
- history.push(routeMatch.url);
+ navigate({
+ params: { id: String(firewallId) },
+ to:
+ type === 'linode'
+ ? '/firewalls/$id/linodes'
+ : '/firewalls/$id/nodebalancers',
+ });
};
const handleOpen = () => {
- setDeviceDrawerOpen(true);
- history.push(routeMatch.url + '/add');
+ navigate({
+ params: { id: String(firewallId) },
+ to:
+ type === 'linode'
+ ? '/firewalls/$id/linodes/add'
+ : '/firewalls/$id/nodebalancers/add',
+ });
};
const [searchText, setSearchText] = React.useState('');
const filter = (value: string) => {
setSearchText(value);
- const filtered = devices?.filter((device) => {
- return device.entity.label.toLowerCase().includes(value.toLowerCase());
- });
- setFilteredDevices(filtered ?? []);
};
+ const [device, setDevice] = React.useState(
+ undefined
+ );
const formattedType = formattedTypes[type];
+ // If the user initiates a history -/+ to a /remove route and the device is not found,
+ // push navigation to the appropriate /linodes or /nodebalancers route.
+ React.useEffect(() => {
+ if (!device && location.pathname.endsWith('remove')) {
+ navigate({
+ params: { id: String(firewallId) },
+ to:
+ type === 'linode'
+ ? '/firewalls/$id/linodes'
+ : '/firewalls/$id/nodebalancers',
+ });
+ }
+ }, [device, location.pathname, firewallId, type, navigate]);
+
return (
<>
{disabled ? (
@@ -120,10 +97,12 @@ export const FirewallDeviceLanding = React.memo(
A {formattedType} can only be assigned to a single Firewall.
{
- setSelectedDeviceId(id);
- setIsRemoveDeviceDialogOpen(true);
+ handleRemoveDevice={(device) => {
+ setDevice(device);
+ navigate({
+ params: { id: String(firewallId) },
+ to:
+ type === 'linode'
+ ? '/firewalls/$id/linodes/remove'
+ : '/firewalls/$id/nodebalancers/remove',
+ });
}}
deviceType={type}
- devices={filteredDevices ?? []}
disabled={disabled}
- error={error ?? undefined}
- loading={isLoading}
+ firewallId={firewallId}
+ type={type}
/>
{type === 'linode' ? (
) : (
)}
+ navigate({
+ params: { id: String(firewallId) },
+ to:
+ type === 'linode'
+ ? '/firewalls/$id/linodes'
+ : '/firewalls/$id/nodebalancers',
+ })
+ }
+ device={device}
firewallId={firewallId}
firewallLabel={firewallLabel}
- onClose={() => setIsRemoveDeviceDialogOpen(false)}
onService={undefined}
- open={isRemoveDeviceDialogOpen}
+ open={location.pathname.endsWith('remove')}
/>
>
);
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx
index 7b1cfcb8fc6..c44b59a22e6 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx
@@ -4,27 +4,24 @@ import { Link } from 'react-router-dom';
import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
-import {
- FirewallDeviceActionMenu,
- FirewallDeviceActionMenuProps,
-} from './FirewallDeviceActionMenu';
+import { FirewallDeviceActionMenu } from './FirewallDeviceActionMenu';
+
+import type { FirewallDeviceActionMenuProps } from './FirewallDeviceActionMenu';
export const FirewallDeviceRow = React.memo(
(props: FirewallDeviceActionMenuProps) => {
- const { deviceEntityID, deviceID, deviceLabel, deviceType } = props;
+ const { device } = props;
+ const { id, label, type } = device.entity;
return (
-
+
-
- {deviceLabel}
+
+ {label}
-
+
);
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx
index f56aa9160f1..b57b40568d6 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx
@@ -12,11 +12,33 @@ const devices = ['linode', 'nodebalancer'];
const props = (type: FirewallDeviceEntityType): FirewallDeviceTableProps => ({
deviceType: type,
- devices: firewallDeviceFactory.buildList(2),
disabled: false,
- error: undefined,
- loading: false,
- triggerRemoveDevice: vi.fn(),
+ firewallId: 1,
+ handleRemoveDevice: vi.fn(),
+ type,
+});
+
+const queryMocks = vi.hoisted(() => ({
+ useAllFirewallDevicesQuery: vi.fn().mockReturnValue({}),
+ useNavigate: vi.fn(() => vi.fn()),
+ useSearch: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('@tanstack/react-router', async () => {
+ const actual = await vi.importActual('@tanstack/react-router');
+ return {
+ ...actual,
+ useNavigate: queryMocks.useNavigate,
+ useSearch: queryMocks.useSearch,
+ };
+});
+
+vi.mock('src/queries/firewalls', async () => {
+ const actual = await vi.importActual('src/queries/firewalls');
+ return {
+ ...actual,
+ useAllFirewallDevicesQuery: queryMocks.useAllFirewallDevicesQuery,
+ };
});
devices.forEach((device: FirewallDeviceEntityType) => {
@@ -28,13 +50,24 @@ devices.forEach((device: FirewallDeviceEntityType) => {
const table = getByRole('table');
expect(table).toBeInTheDocument();
});
- });
- it('should contain two rows', () => {
- const { getAllByRole } = renderWithTheme(
-
- );
- const rows = getAllByRole('row');
- expect(rows.length - 1).toBe(2);
+ it('should contain two rows', () => {
+ queryMocks.useAllFirewallDevicesQuery.mockReturnValue({
+ data: firewallDeviceFactory.buildList(2, {
+ entity: {
+ id: 1,
+ label: `test-${device}`,
+ type: device,
+ },
+ }),
+ error: null,
+ isLoading: false,
+ });
+ const { getAllByRole } = renderWithTheme(
+
+ );
+ const rows = getAllByRole('row');
+ expect(rows.length - 1).toBe(2);
+ });
});
});
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx
index 0e001a071c6..69745f8baf8 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx
@@ -1,7 +1,5 @@
import * as React from 'react';
-import OrderBy from 'src/components/OrderBy';
-import Paginate from 'src/components/Paginate';
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
import { Table } from 'src/components/Table';
import { TableBody } from 'src/components/TableBody';
@@ -9,35 +7,40 @@ import { TableContentWrapper } from 'src/components/TableContentWrapper/TableCon
import { TableHead } from 'src/components/TableHead';
import { TableRow } from 'src/components/TableRow';
import { TableSortCell } from 'src/components/TableSortCell';
+import { useOrderV2 } from 'src/hooks/useOrderV2';
+import { usePaginationV2 } from 'src/hooks/usePaginationV2';
+import { useAllFirewallDevicesQuery } from 'src/queries/firewalls';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
-import { formattedTypes } from './FirewallDeviceLanding';
+import { formattedTypes } from './constants';
import { FirewallDeviceRow } from './FirewallDeviceRow';
-import type { FirewallDeviceEntityType } from '@linode/api-v4';
-import type { FirewallDevice } from '@linode/api-v4/lib/firewalls/types';
-import type { APIError } from '@linode/api-v4/lib/types';
+import type { FirewallDevice, FirewallDeviceEntityType } from '@linode/api-v4';
export interface FirewallDeviceTableProps {
deviceType: FirewallDeviceEntityType;
- devices: FirewallDevice[];
disabled: boolean;
- error?: APIError[];
- loading: boolean;
- triggerRemoveDevice: (deviceID: number) => void;
+ firewallId: number;
+ handleRemoveDevice: (device: FirewallDevice) => void;
+ type: FirewallDeviceEntityType;
}
export const FirewallDeviceTable = React.memo(
(props: FirewallDeviceTableProps) => {
const {
deviceType,
- devices,
disabled,
- error,
- loading,
- triggerRemoveDevice,
+ firewallId,
+ handleRemoveDevice,
+ type,
} = props;
+ const { data: allDevices, error, isLoading } = useAllFirewallDevicesQuery(
+ firewallId
+ );
+ const devices =
+ allDevices?.filter((device) => device.entity.type === type) || [];
+
const _error = error
? getAPIErrorOrDefault(
error,
@@ -47,67 +50,77 @@ export const FirewallDeviceTable = React.memo(
const ariaLabel = `List of ${formattedTypes[deviceType]}s attached to this firewall`;
+ const {
+ handleOrderChange,
+ order,
+ orderBy,
+ sortedData: sortedDevices,
+ } = useOrderV2({
+ data: devices,
+ initialRoute: {
+ defaultOrder: {
+ order: 'asc',
+ orderBy: `entity:label`,
+ },
+ from:
+ deviceType === 'linode'
+ ? '/firewalls/$id/linodes'
+ : '/firewalls/$id/nodebalancers',
+ },
+ preferenceKey: `${deviceType}s-order`,
+ });
+
+ const pagination = usePaginationV2({
+ currentRoute:
+ deviceType === 'linode'
+ ? '/firewalls/$id/linodes'
+ : '/firewalls/$id/nodebalancers',
+ preferenceKey: `${deviceType}s-pagination`,
+ });
+
return (
-
- {({ data: orderedData, handleOrderChange, order, orderBy }) => (
-
- {({
- count,
- data: paginatedAndOrderedData,
- handlePageChange,
- handlePageSizeChange,
- page,
- pageSize,
- }) => (
- <>
-
-
-
-
- {formattedTypes[deviceType]}
-
-
-
-
-
- {paginatedAndOrderedData.map((thisDevice) => (
-
- ))}
-
-
-
-
+
+
+
+
+ {formattedTypes[deviceType]}
+
+
+
+
+
+ {sortedDevices?.map((thisDevice) => (
+
- >
- )}
-
- )}
-
+ ))}
+
+
+
+
+ >
);
}
);
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts
new file mode 100644
index 00000000000..9234255a29c
--- /dev/null
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts
@@ -0,0 +1,7 @@
+import type { FirewallDeviceEntityType } from '@linode/api-v4';
+
+export const formattedTypes: Record = {
+ interface: 'Interface', // @TODO Linode Interface: double check this when working on UI tickets
+ linode: 'Linode',
+ nodebalancer: 'NodeBalancer',
+};
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx
index f9277c7c6d9..3888e665cb8 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx
@@ -3,17 +3,16 @@ import * as React from 'react';
import { renderWithTheme } from 'src/utilities/testHelpers';
-import {
- FirewallRuleActionMenu,
- FirewallRuleActionMenuProps,
-} from './FirewallRuleActionMenu';
+import { FirewallRuleActionMenu } from './FirewallRuleActionMenu';
+
+import type { FirewallRuleActionMenuProps } from './FirewallRuleActionMenu';
const props: FirewallRuleActionMenuProps = {
disabled: false,
+ handleCloneFirewallRule: vi.fn(),
+ handleDeleteFirewallRule: vi.fn(),
+ handleOpenRuleDrawerForEditing: vi.fn(),
idx: 1,
- triggerCloneFirewallRule: vi.fn(),
- triggerDeleteFirewallRule: vi.fn(),
- triggerOpenRuleDrawerForEditing: vi.fn(),
};
describe('Firewall rule action menu', () => {
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx
index 8ea1e58f141..3f3313022a3 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx
@@ -1,20 +1,22 @@
-import { Theme, useTheme } from '@mui/material/styles';
+import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import * as React from 'react';
-import {
+import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
+import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
+
+import type { Theme } from '@mui/material/styles';
+import type {
Action,
- ActionMenu,
ActionMenuProps,
} from 'src/components/ActionMenu/ActionMenu';
-import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
export interface FirewallRuleActionMenuProps extends Partial {
disabled: boolean;
+ handleCloneFirewallRule: (idx: number) => void;
+ handleDeleteFirewallRule: (idx: number) => void;
+ handleOpenRuleDrawerForEditing: (idx: number) => void;
idx: number;
- triggerCloneFirewallRule: (idx: number) => void;
- triggerDeleteFirewallRule: (idx: number) => void;
- triggerOpenRuleDrawerForEditing: (idx: number) => void;
}
export const FirewallRuleActionMenu = React.memo(
@@ -24,10 +26,10 @@ export const FirewallRuleActionMenu = React.memo(
const {
disabled,
+ handleCloneFirewallRule,
+ handleDeleteFirewallRule,
+ handleOpenRuleDrawerForEditing,
idx,
- triggerCloneFirewallRule,
- triggerDeleteFirewallRule,
- triggerOpenRuleDrawerForEditing,
...actionMenuProps
} = props;
@@ -35,21 +37,21 @@ export const FirewallRuleActionMenu = React.memo(
{
disabled,
onClick: () => {
- triggerOpenRuleDrawerForEditing(idx);
+ handleOpenRuleDrawerForEditing(idx);
},
title: 'Edit',
},
{
disabled,
onClick: () => {
- triggerCloneFirewallRule(idx);
+ handleCloneFirewallRule(idx);
},
title: 'Clone',
},
{
disabled,
onClick: () => {
- triggerDeleteFirewallRule(idx);
+ handleDeleteFirewallRule(idx);
},
title: 'Delete',
},
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx
index 2cea8507824..6d08b9775f0 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx
@@ -1,9 +1,9 @@
import { Typography } from '@linode/ui';
+import { capitalize } from '@linode/utilities';
import { Formik } from 'formik';
import * as React from 'react';
import { Drawer } from 'src/components/Drawer';
-import { capitalize } from 'src/utilities/capitalize';
import {
formValueToIPs,
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx
index 10e2549a106..ee159ac0d72 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx
@@ -7,6 +7,7 @@ import {
TextField,
Typography,
} from '@linode/ui';
+import { capitalize } from '@linode/utilities';
import { styled } from '@mui/material/styles';
import * as React from 'react';
@@ -18,7 +19,6 @@ import {
portPresets,
protocolOptions,
} from 'src/features/Firewalls/shared';
-import { capitalize } from 'src/utilities/capitalize';
import { ipFieldPlaceholder } from 'src/utilities/ipUtils';
import { enforceIPMasks } from './FirewallRuleDrawer.utils';
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx
index d827cc3e86d..f7e966e6447 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx
@@ -15,13 +15,14 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Box, Typography } from '@linode/ui';
+import { Autocomplete } from '@linode/ui';
+import { capitalize } from '@linode/utilities';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import { prop, uniqBy } from 'ramda';
import * as React from 'react';
import Undo from 'src/assets/icons/undo.svg';
-import { Autocomplete } from '@linode/ui';
import { Hidden } from 'src/components/Hidden';
import { MaskableText } from 'src/components/MaskableText/MaskableText';
import { Table } from 'src/components/Table';
@@ -35,7 +36,6 @@ import {
generateRuleLabel,
predefinedFirewallFromRule as ruleToPredefinedFirewall,
} from 'src/features/Firewalls/shared';
-import { capitalize } from 'src/utilities/capitalize';
import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor';
import { FirewallRuleActionMenu } from './FirewallRuleActionMenu';
@@ -78,11 +78,11 @@ interface RuleRow {
// =============================================================================
interface RowActionHandlers {
- triggerCloneFirewallRule: (idx: number) => void;
- triggerDeleteFirewallRule: (idx: number) => void;
- triggerOpenRuleDrawerForEditing: (idx: number) => void;
- triggerReorder: (startIdx: number, endIdx: number) => void;
- triggerUndo: (idx: number) => void;
+ handleCloneFirewallRule: (idx: number) => void;
+ handleDeleteFirewallRule: (idx: number) => void;
+ handleOpenRuleDrawerForEditing: (idx: number) => void;
+ handleReorder: (startIdx: number, endIdx: number) => void;
+ handleUndo: (idx: number) => void;
}
interface FirewallRuleTableProps extends RowActionHandlers {
@@ -101,15 +101,15 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => {
const {
category,
disabled,
+ handleCloneFirewallRule,
+ handleDeleteFirewallRule,
+ handleOpenRuleDrawerForEditing,
handlePolicyChange,
+ handleReorder,
+ handleUndo,
openRuleDrawer,
policy,
rulesWithStatus,
- triggerCloneFirewallRule,
- triggerDeleteFirewallRule,
- triggerOpenRuleDrawerForEditing,
- triggerReorder,
- triggerUndo,
} = props;
const theme = useTheme();
@@ -141,7 +141,7 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => {
if (active && over && active.id !== over.id) {
const sourceIndex = getRowDataIndex(Number(active.id));
const destinationIndex = getRowDataIndex(Number(over.id));
- triggerReorder(sourceIndex, destinationIndex);
+ handleReorder(sourceIndex, destinationIndex);
}
// Remove focus from the initial position when the drag ends.
@@ -238,16 +238,16 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => {
aria-label={
thisRuleRow.label ?? `firewall rule ${thisRuleRow.id}`
}
- triggerOpenRuleDrawerForEditing={
- triggerOpenRuleDrawerForEditing
+ handleOpenRuleDrawerForEditing={
+ handleOpenRuleDrawerForEditing
}
aria-roledescription={screenReaderMessage}
aria-selected={false}
disabled={disabled}
+ handleCloneFirewallRule={handleCloneFirewallRule}
+ handleDeleteFirewallRule={handleDeleteFirewallRule}
+ handleUndo={handleUndo}
key={thisRuleRow.id}
- triggerCloneFirewallRule={triggerCloneFirewallRule}
- triggerDeleteFirewallRule={triggerDeleteFirewallRule}
- triggerUndo={triggerUndo}
{...thisRuleRow}
id={thisRuleRow.id}
/>
@@ -278,10 +278,10 @@ interface RowActionHandlersWithDisabled
export interface FirewallRuleTableRowProps extends RuleRow {
disabled: RowActionHandlersWithDisabled['disabled'];
- triggerCloneFirewallRule: RowActionHandlersWithDisabled['triggerCloneFirewallRule'];
- triggerDeleteFirewallRule: RowActionHandlersWithDisabled['triggerDeleteFirewallRule'];
- triggerOpenRuleDrawerForEditing: RowActionHandlersWithDisabled['triggerOpenRuleDrawerForEditing'];
- triggerUndo: RowActionHandlersWithDisabled['triggerUndo'];
+ handleCloneFirewallRule: RowActionHandlersWithDisabled['handleCloneFirewallRule'];
+ handleDeleteFirewallRule: RowActionHandlersWithDisabled['handleDeleteFirewallRule'];
+ handleOpenRuleDrawerForEditing: RowActionHandlersWithDisabled['handleOpenRuleDrawerForEditing'];
+ handleUndo: RowActionHandlersWithDisabled['handleUndo'];
}
const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => {
@@ -290,6 +290,10 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => {
addresses,
disabled,
errors,
+ handleCloneFirewallRule,
+ handleDeleteFirewallRule,
+ handleOpenRuleDrawerForEditing,
+ handleUndo,
id,
index,
label,
@@ -297,18 +301,14 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => {
ports,
protocol,
status,
- triggerCloneFirewallRule,
- triggerDeleteFirewallRule,
- triggerOpenRuleDrawerForEditing,
- triggerUndo,
} = props;
const actionMenuProps = {
disabled: status === 'PENDING_DELETION' || disabled,
+ handleCloneFirewallRule,
+ handleDeleteFirewallRule,
+ handleOpenRuleDrawerForEditing,
idx: index,
- triggerCloneFirewallRule,
- triggerDeleteFirewallRule,
- triggerOpenRuleDrawerForEditing,
};
const theme = useTheme();
@@ -363,7 +363,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => {
{label || (
triggerOpenRuleDrawerForEditing(index)}
+ onClick={() => handleOpenRuleDrawerForEditing(index)}
>
Add a label
@@ -395,7 +395,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => {
triggerUndo(index)}
+ onClick={() => handleUndo(index)}
status={status}
>
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx
index c773be919e3..f71cbf8f854 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx
@@ -1,11 +1,13 @@
import { Notice, Typography } from '@linode/ui';
import { styled } from '@mui/material/styles';
import { useQueryClient } from '@tanstack/react-query';
+import { useBlocker, useLocation, useNavigate } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import * as React from 'react';
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
+// eslint-disable-next-line no-restricted-imports
import { Prompt } from 'src/components/Prompt/Prompt';
import {
useAllFirewallDevicesQuery,
@@ -44,7 +46,6 @@ interface Props {
interface Drawer {
category: Category;
- isOpen: boolean;
mode: FirewallRuleDrawerMode;
ruleIdx?: number;
}
@@ -58,7 +59,8 @@ export const FirewallRulesLanding = React.memo((props: Props) => {
);
const { data: devices } = useAllFirewallDevicesQuery(firewallID);
const queryClient = useQueryClient();
-
+ const navigate = useNavigate();
+ const location = useLocation();
const { enqueueSnackbar } = useSnackbar();
/**
@@ -87,7 +89,6 @@ export const FirewallRulesLanding = React.memo((props: Props) => {
*/
const [ruleDrawer, setRuleDrawer] = React.useState({
category: 'inbound',
- isOpen: false,
mode: 'create',
});
const [submitting, setSubmitting] = React.useState(false);
@@ -104,15 +105,32 @@ export const FirewallRulesLanding = React.memo((props: Props) => {
category: Category,
mode: FirewallRuleDrawerMode,
idx?: number
- ) =>
+ ) => {
setRuleDrawer({
category,
- isOpen: true,
mode,
ruleIdx: idx,
});
+ navigate({
+ params: { id: String(firewallID), ruleId: String(idx) },
+ to:
+ category === 'inbound' && mode === 'create'
+ ? '/firewalls/$id/rules/add/inbound'
+ : category === 'inbound' && mode === 'edit'
+ ? `/firewalls/$id/rules/edit/inbound/$ruleId`
+ : category === 'outbound' && mode === 'create'
+ ? '/firewalls/$id/rules/add/outbound'
+ : `/firewalls/$id/rules/edit/outbound/$ruleId`,
+ });
+ };
- const closeRuleDrawer = () => setRuleDrawer({ ...ruleDrawer, isOpen: false });
+ const closeRuleDrawer = () => {
+ setRuleDrawer({ ...ruleDrawer });
+ navigate({
+ params: { id: String(firewallID) },
+ to: '/firewalls/$id/rules',
+ });
+ };
/**
* Rule Editor state hand handlers
@@ -267,6 +285,41 @@ export const FirewallRulesLanding = React.memo((props: Props) => {
[inboundState, outboundState, policy, rules]
);
+ const { proceed, reset, status } = useBlocker({
+ enableBeforeUnload: hasUnsavedChanges,
+ shouldBlockFn: ({ next }) => {
+ // Only block if there are unsaved changes
+ if (!hasUnsavedChanges) {
+ return false;
+ }
+
+ // Don't block navigation to these specific routes, since they are part of the current form
+ const isNavigatingToAllowedRoute =
+ next.routeId === '/firewalls/$id/rules' ||
+ next.routeId === '/firewalls/$id/rules/add/inbound' ||
+ next.routeId === '/firewalls/$id/rules/add/outbound' ||
+ next.routeId === '/firewalls/$id/rules/edit/inbound/$ruleId' ||
+ next.routeId === '/firewalls/$id/rules/edit/outbound/$ruleId';
+
+ return !isNavigatingToAllowedRoute;
+ },
+ withResolver: true,
+ });
+
+ // Create a combined handler for proceeding with navigation
+ const handleProceedNavigation = React.useCallback(() => {
+ if (status === 'blocked' && proceed) {
+ proceed();
+ }
+ }, [status, proceed]);
+
+ // Create a combined handler for canceling navigation
+ const handleCancelNavigation = React.useCallback(() => {
+ if (status === 'blocked' && reset) {
+ reset();
+ }
+ }, [status, reset]);
+
const inboundRules = React.useMemo(() => editorStateToRules(inboundState), [
inboundState,
]);
@@ -285,34 +338,47 @@ export const FirewallRulesLanding = React.memo((props: Props) => {
return (
<>
+ {/*
+ This Prompt eventually can be removed once react-router is fully deprecated
+ It is here only to preserve the behavior of non-Tanstack routes
+ */}
- {({ handleCancel, handleConfirm, isModalOpen }) => {
- return (
- (
-
- )}
- onClose={handleCancel}
- open={isModalOpen}
- title="Discard Firewall changes?"
- >
-
- The changes you made to this Firewall haven’t been
- applied. If you navigate away from this page, your changes will
- be discarded.
-
-
- );
- }}
+ {({ handleCancel, handleConfirm, isModalOpen }) => (
+ (
+ {
+ handleCancelNavigation();
+ handleCancel();
+ },
+ }}
+ secondaryButtonProps={{
+ buttonType: 'secondary',
+ color: 'error',
+ label: 'Leave and discard changes',
+ onClick: () => {
+ handleProceedNavigation();
+ handleConfirm();
+ },
+ }}
+ />
+ )}
+ onClose={() => {
+ handleCancelNavigation();
+ handleCancel();
+ }}
+ open={status === 'blocked' || isModalOpen}
+ title="Discard Firewall changes?"
+ >
+