From 5a20623c40077c403c1ede5db39d7d2f59d18085 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:49:50 +0100 Subject: [PATCH 01/42] change: [UIE-8228] - Database Resize: disable same size plan (#11481) * change: [UIE-8228] - Database Resize: disable same size plan * Added changeset: Database Resize: Disable plans when the usable storage equals the used storage of the database cluster --- .../pr-11481-changed-1736250933443.md | 5 + .../DatabaseResize/DatabaseResize.test.tsx | 205 +++++++----------- .../DatabaseResize/DatabaseResize.tsx | 25 +-- .../DatabaseResize/DatabaseResize.utils.tsx | 39 ++++ 4 files changed, 131 insertions(+), 143 deletions(-) create mode 100644 packages/manager/.changeset/pr-11481-changed-1736250933443.md create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx diff --git a/packages/manager/.changeset/pr-11481-changed-1736250933443.md b/packages/manager/.changeset/pr-11481-changed-1736250933443.md new file mode 100644 index 00000000000..9c13c4f4e4c --- /dev/null +++ b/packages/manager/.changeset/pr-11481-changed-1736250933443.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Database Resize: Disable plans when the usable storage equals the used storage of the database cluster ([#11481](https://github.com/linode/manager/pull/11481)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx index 455cf52e3ba..ba9020b3df2 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx @@ -1,7 +1,4 @@ -import { - queryByAttribute, - waitForElementToBeRemoved, -} from '@testing-library/react'; +import { waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import * as React from 'react'; @@ -17,6 +14,9 @@ import { HttpResponse, http, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { DatabaseResize } from './DatabaseResize'; +import { isSmallerOrEqualCurrentPlan } from './DatabaseResize.utils'; + +import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; const loadingTestId = 'circle-progress'; @@ -24,22 +24,26 @@ beforeAll(() => mockMatchMedia()); describe('database resize', () => { const database = databaseFactory.build(); + const mockDatabase = databaseFactory.build({ + cluster_size: 3, + engine: 'mysql', + platform: 'rdbms-default', + type: 'g6-nanode-1', + used_disk_size_gb: 0, + }); const dedicatedTypes = databaseTypeFactory.buildList(7, { class: 'dedicated', }); - - it('should render a loading state', async () => { - // Mock database types - const standardTypes = [ - databaseTypeFactory.build({ - class: 'nanode', - id: 'g6-nanode-1', - label: `Nanode 1 GB`, - memory: 1024, - }), - ...databaseTypeFactory.buildList(7, { class: 'standard' }), - ]; - + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-nanode-1', + label: `Nanode 1 GB`, + memory: 1024, + }), + ...databaseTypeFactory.buildList(7, { class: 'standard' }), + ]; + beforeEach(() => { server.use( http.get('*/databases/types', () => { return HttpResponse.json( @@ -51,38 +55,17 @@ describe('database resize', () => { return HttpResponse.json(account); }) ); + }); + it('should render a loading state', async () => { const { getByTestId } = renderWithTheme( ); - // Should render a loading state expect(getByTestId(loadingTestId)).toBeInTheDocument(); }); it('should render configuration, summary sections and input field to choose a plan', async () => { - // Mock database types - const standardTypes = [ - databaseTypeFactory.build({ - class: 'nanode', - id: 'g6-nanode-1', - label: `Nanode 1 GB`, - memory: 1024, - }), - ...databaseTypeFactory.buildList(7, { class: 'standard' }), - ]; - server.use( - http.get('*/databases/types', () => { - return HttpResponse.json( - makeResourcePage([...standardTypes, ...dedicatedTypes]) - ); - }), - http.get('*/account', () => { - const account = accountFactory.build(); - return HttpResponse.json(account); - }) - ); - const { getByTestId, getByText } = renderWithTheme( ); @@ -96,34 +79,12 @@ describe('database resize', () => { }); describe('On rendering of page', () => { - const examplePlanType = 'g6-dedicated-50'; - const dedicatedTypes = databaseTypeFactory.buildList(7, { - class: 'dedicated', - }); - const database = databaseFactory.build(); - beforeEach(() => { - // Mock database types - const standardTypes = [ - databaseTypeFactory.build({ - class: 'nanode', - id: 'g6-nanode-1', - label: `Nanode 1 GB`, - memory: 1024, - }), - ...databaseTypeFactory.buildList(7, { class: 'standard' }), - ]; - server.use( - http.get('*/databases/types', () => { - return HttpResponse.json( - makeResourcePage([...standardTypes, ...dedicatedTypes]) - ); - }), - http.get('*/account', () => { - const account = accountFactory.build(); - return HttpResponse.json(account); - }) - ); - }); + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; it('resize button should be disabled when no input is provided in the form', async () => { const { getByTestId, getByText } = renderWithTheme( @@ -139,26 +100,35 @@ describe('database resize', () => { // Mock route history so the Plan Selection table displays prices without requiring a region in the DB resize flow. const history = createMemoryHistory(); history.push(`databases/${database.engine}/${database.id}/resize`); - const { container, getByTestId, getByText } = renderWithTheme( + const { getByRole, getByTestId, getByText } = renderWithTheme( - - + + , + { flags } ); await waitForElementToBeRemoved(getByTestId(loadingTestId)); - const getById = queryByAttribute.bind(null, 'id'); - await userEvent.click(getById(container, examplePlanType)); + + const planRadioButton = document.getElementById('g6-standard-6'); + await userEvent.click(planRadioButton as HTMLInputElement); + const resizeButton = getByText(/Resize Database Cluster/i); expect(resizeButton.closest('button')).toHaveAttribute( 'aria-disabled', 'false' ); + await userEvent.click(resizeButton); - getByText(`Resize Database Cluster ${database.label}?`); + + const dialogElement = getByRole('dialog'); + expect(dialogElement).toBeInTheDocument(); + expect(dialogElement).toHaveTextContent( + `Resize Database Cluster ${mockDatabase.label}?` + ); }); it('Should disable the "Resize Database Cluster" button when disabled = true', async () => { const { getByTestId, getByText } = renderWithTheme( - + ); await waitForElementToBeRemoved(getByTestId(loadingTestId)); const resizeDatabaseBtn = getByText('Resize Database Cluster').closest( @@ -169,13 +139,6 @@ describe('database resize', () => { }); describe('on rendering of page and isDatabasesV2GA is true and the Shared CPU tab is preselected ', () => { - const mockDatabase = databaseFactory.build({ - cluster_size: 3, - engine: 'mysql', - platform: 'rdbms-default', - type: 'g6-nanode-1', - }); - const flags = { dbaasV2: { beta: false, @@ -403,51 +366,41 @@ describe('database resize', () => { }); describe('should be disabled smaller plans', () => { - const database = databaseFactory.build({ - type: 'g6-dedicated-8', - }); + // Mock database types + const dedicatedTypes = [ + databaseTypeFactory.build({ + class: 'dedicated', + disk: 1, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + }) as PlanSelectionWithDatabaseType, + databaseTypeFactory.build({ + class: 'dedicated', + disk: 2, + id: 'g6-dedicated-4', + label: 'Dedicated 8 GB', + }) as PlanSelectionWithDatabaseType, + databaseTypeFactory.build({ + class: 'dedicated', + disk: 3, + id: 'g6-dedicated-8', + label: `Linode 16 GB`, + }) as PlanSelectionWithDatabaseType, + ]; + + const disabledTypes = isSmallerOrEqualCurrentPlan( + 'g6-dedicated-8', + 3, + dedicatedTypes, + true + ); + it('disabled smaller plans', async () => { - // Mock database types - const dedicatedTypes = [ - databaseTypeFactory.build({ - class: 'dedicated', - disk: 81920, - id: 'g6-dedicated-2', - label: 'Dedicated 4 GB', - memory: 4096, - }), - databaseTypeFactory.build({ - class: 'dedicated', - disk: 163840, - id: 'g6-dedicated-4', - label: 'Dedicated 8 GB', - memory: 8192, - }), - databaseTypeFactory.build({ - class: 'dedicated', - disk: 327680, - id: 'g6-dedicated-8', - label: `Linode 16 GB`, - memory: 16384, - }), - ]; - server.use( - http.get('*/databases/types', () => { - return HttpResponse.json(makeResourcePage([...dedicatedTypes])); - }), - http.get('*/account', () => { - const account = accountFactory.build(); - return HttpResponse.json(account); - }) - ); - const { getByTestId } = renderWithTheme( - - ); - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - expect( - document.getElementById('g6-dedicated-4')?.hasAttribute('disabled') - ); + expect(disabledTypes.includes(dedicatedTypes[1])).toBe(true); + }); + + it('disable plan if it is the same size as used storage', async () => { + expect(disabledTypes.includes(dedicatedTypes[2])).toBe(true); }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index ab03c04d23d..e9e007c2279 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -18,16 +18,16 @@ import { DatabaseSummarySection } from 'src/features/Databases/DatabaseCreate/Da import { DatabaseResizeCurrentConfiguration } from 'src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; -import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useDatabaseMutation } from 'src/queries/databases/databases'; +import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; -import { convertMegabytesTo } from 'src/utilities/unitConversions'; import { StyledGrid, StyledPlansPanel, StyledResizeButton, } from './DatabaseResize.style'; +import { isSmallerOrEqualCurrentPlan } from './DatabaseResize.utils'; import type { ClusterSize, @@ -216,21 +216,12 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { setSelectedTab(initialTab); }, []); - const currentPlanDisk = currentPlan ? currentPlan.disk : 0; - const disabledPlans = !isNewDatabaseGA - ? displayTypes?.filter((type) => - type.class === 'dedicated' - ? type.disk < currentPlanDisk - : type.disk <= currentPlanDisk - ) - : displayTypes?.filter( - (type) => - database?.used_disk_size_gb && - database.used_disk_size_gb > - +convertMegabytesTo(type.disk, true) - .split(/(GB|MB|KB)/i)[0] - .trim() - ); + const disabledPlans = isSmallerOrEqualCurrentPlan( + currentPlan?.id, + database?.used_disk_size_gb, + displayTypes, + isNewDatabaseGA + ); const isDisabledSharedTab = database.cluster_size === 2; const shouldSubmitBeDisabled = React.useMemo(() => { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx new file mode 100644 index 00000000000..4954019f1f0 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx @@ -0,0 +1,39 @@ +import { convertMegabytesTo } from 'src/utilities/unitConversions'; + +import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; + +/** + * Filters a list of plans based on the current plan's disk size or the current used disk size. + * + * @param {string | undefined} currentPlanID - The ID of the current plan. + * @param {null | number} currentUsedDiskSize - The current used disk size. + * @param {PlanSelectionWithDatabaseType[]} types - The list of available plans to filter. + * @param {boolean} [isNewDatabase] - Optional flag indicating whether the database is new. If true, the filtering logic based on disk usage is applied. + * + * @returns {PlanSelectionWithDatabaseType[]} A filtered list of plans based on the conditions: + * - If `isNewDatabase` is false and `currentPlanID` is provided, plans with disk sizes smaller or equal to the current plan are included. + * - If `isNewDatabase` is true, plans are filtered based on their disk size compared to the current used disk size. + */ +export const isSmallerOrEqualCurrentPlan = ( + currentPlanID: string | undefined, + currentUsedDiskSize: null | number, + types: PlanSelectionWithDatabaseType[], + isNewDatabase?: boolean +) => { + const currentType = types.find((thisType) => thisType.id === currentPlanID); + + return !isNewDatabase && currentType + ? types?.filter((type) => + type.class === 'dedicated' + ? type.disk < currentType?.disk + : type.disk <= currentType?.disk + ) + : types?.filter( + (type) => + currentUsedDiskSize && + currentUsedDiskSize >= + +convertMegabytesTo(type.disk, true) + .split(/(GB|MB|KB)/i)[0] + .trim() + ); +}; From 473bc3d74922a8fc3128ba3244159bce0ae78f89 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:59:22 -0500 Subject: [PATCH 02/42] test: Fix DateTimeRangePicker unit test failure (#11502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description ๐Ÿ“ This attempts to fix a unit test failure in `DateTimeRangePicker.test.ts` when the test is run in an environment whose system locale is not set to central time (UTC-06:00). Also does some clean up related to the way that the system time is mocked. ## Changes ๐Ÿ”„ - Fix test failure in DateTimeRangePicker unit tests - Clean up ## How to test ๐Ÿงช ```bash yarn test DateTimeRangePicker.test ``` --- .../DatePicker/DateTimeRangePicker.test.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index 02bdd4f808b..3dc542f4c36 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -27,14 +27,12 @@ const Props: DateTimeRangePickerProps = { }; describe('DateTimeRangePicker Component', () => { - let fixedNow: DateTime; - beforeEach(() => { // Mock DateTime.now to return a fixed datetime - fixedNow = DateTime.fromISO( + const fixedNow = DateTime.fromISO( '2024-12-18T00:28:27.071-06:00' - ) as DateTime; - vi.spyOn(DateTime, 'now').mockImplementation(() => fixedNow); + ).toUTC() as DateTime; + vi.setSystemTime(fixedNow.toJSDate()); }); afterEach(() => { @@ -51,8 +49,7 @@ describe('DateTimeRangePicker Component', () => { }); it('should call onChange when start date is changed', async () => { - const currentYear = new Date().getFullYear(); // Dynamically get the current year - const currentMonth = String(new Date().getMonth() + 1).padStart(2, '0'); // Get current month (1-based) + vi.setSystemTime(vi.getRealSystemTime()); renderWithTheme(); @@ -62,11 +59,17 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(screen.getByRole('gridcell', { name: '10' })); await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + const expectedStartTime = DateTime.fromObject({ + day: 10, + month: DateTime.now().month, + year: DateTime.now().year, + }).toISO(); + // Check if the onChange function is called with the expected value expect(onChangeMock).toHaveBeenCalledWith({ end: null, preset: 'custom_range', - start: `${currentYear}-${currentMonth}-10T00:00:00.000-06:00`, + start: expectedStartTime, timeZone: null, }); }); @@ -164,8 +167,8 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(last24HoursOption); // Expected start and end dates in ISO format - const expectedStartDateISO = fixedNow.minus({ hours: 24 }).toISO(); // 2024-12-17T00:28:27.071-06:00 - const expectedEndDateISO = fixedNow.toISO(); // 2024-12-18T00:28:27.071-06:00 + const expectedStartDateISO = DateTime.now().minus({ hours: 24 }).toISO(); // 2024-12-17T00:28:27.071-06:00 + const expectedEndDateISO = DateTime.now().toISO(); // 2024-12-18T00:28:27.071-06:00 // Verify onChangeMock was called with correct ISO strings expect(onChangeMock).toHaveBeenCalledWith({ @@ -191,8 +194,8 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(last7DaysOption); // Expected start and end dates in ISO format - const expectedStartDateISO = fixedNow.minus({ days: 7 }).toISO(); - const expectedEndDateISO = fixedNow.toISO(); + const expectedStartDateISO = DateTime.now().minus({ days: 7 }).toISO(); + const expectedEndDateISO = DateTime.now().toISO(); // Verify that onChange is called with the correct date range expect(onChangeMock).toHaveBeenCalledWith({ @@ -218,8 +221,8 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(last30DaysOption); // Expected start and end dates in ISO format - const expectedStartDateISO = fixedNow.minus({ days: 30 }).toISO(); - const expectedEndDateISO = fixedNow.toISO(); + const expectedStartDateISO = DateTime.now().minus({ days: 30 }).toISO(); + const expectedEndDateISO = DateTime.now().toISO(); // Verify that onChange is called with the correct date range expect(onChangeMock).toHaveBeenCalledWith({ @@ -245,8 +248,8 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(thisMonthOption); // Expected start and end dates in ISO format - const expectedStartDateISO = fixedNow.startOf('month').toISO(); - const expectedEndDateISO = fixedNow.endOf('month').toISO(); + const expectedStartDateISO = DateTime.now().startOf('month').toISO(); + const expectedEndDateISO = DateTime.now().endOf('month').toISO(); // Verify that onChange is called with the correct date range expect(onChangeMock).toHaveBeenCalledWith({ @@ -271,7 +274,7 @@ describe('DateTimeRangePicker Component', () => { const lastMonthOption = screen.getByText('Last Month'); await userEvent.click(lastMonthOption); - const lastMonth = fixedNow.minus({ months: 1 }); + const lastMonth = DateTime.now().minus({ months: 1 }); // Expected start and end dates in ISO format const expectedStartDateISO = lastMonth.startOf('month').toISO(); From d35c467c5803db6f725114082a35a038729a6721 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:26:24 -0500 Subject: [PATCH 03/42] fix: Preferences type_to_confirm being undefined no longer causes button to be disabled (#11500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description ๐Ÿ“ There is an edge case where `preferences?.type_to_confirm` is `undefined` when the user has not yet set a preference as seen in `/profile/settings?preferenceEditor=true`. ## Changes ๐Ÿ”„ - Ensure that `preferences?.type_to_confirm` resolves to a boolean value (default true) ## How to test ๐Ÿงช ### Prerequisites - Removed `type_to_confirm` preference from /profile/settings?preferenceEditor=true ### Reproduction steps - Observe the disabled button for linode deletion dialogs and others ### Verification steps #### Step 1 - Removed `type_to_confirm` preference from /profile/settings?preferenceEditor=true - Ensure type-to-confirm is enabled by default - Check linode: delete, resize, rebuild - Check domain: delete - Check close account still works as intended #### Step 2 - Disable type-to-confirm - Check linode: delete, resize, rebuild - Check domain: delete - Check close account still works as intended --------- Co-authored-by: Jaalah Ramos Co-authored-by: Hana Xu --- .../DeletionDialog/DeletionDialog.stories.tsx | 4 -- .../DeletionDialog/DeletionDialog.test.tsx | 40 +++++++++++++--- .../DeletionDialog/DeletionDialog.tsx | 18 ++----- .../TypeToConfirm/TypeToConfirm.test.tsx | 48 +++++++++++++++++-- .../TypeToConfirm/TypeToConfirm.tsx | 22 ++++++--- .../TypeToConfirmDialog.tsx | 4 +- .../src/features/Domains/DeleteDomain.tsx | 1 - .../src/features/Domains/DomainsLanding.tsx | 1 - .../LinodeRebuild/RebuildFromImage.tsx | 4 +- .../LinodeRebuild/RebuildFromStackScript.tsx | 4 +- .../LinodeResize/LinodeResize.tsx | 4 +- .../Profile/Settings/TypeToConfirm.tsx | 12 ++--- 12 files changed, 109 insertions(+), 53 deletions(-) diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx index e9372ce1eea..278748ecc43 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx @@ -33,9 +33,6 @@ const meta: Meta = { open: { description: 'Is the modal open?', }, - typeToConfirm: { - description: `Whether or not a user is required to type the enity's label to delete.`, - }, }, args: { disableAutoFocus: true, @@ -52,7 +49,6 @@ const meta: Meta = { onDelete: action('onDelete'), open: true, style: { position: 'unset' }, - typeToConfirm: true, }, component: DeletionDialog, title: 'Components/Dialog/DeletionDialog', diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx index c1385003852..ad5747dda1d 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx @@ -6,6 +6,25 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { DeletionDialog } from './DeletionDialog'; import type { DeletionDialogProps } from './DeletionDialog'; +import type { ManagerPreferences } from 'src/types/ManagerPreferences'; + +const preference: ManagerPreferences['type_to_confirm'] = true; + +const queryMocks = vi.hoisted(() => ({ + usePreferences: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/profile/preferences', async () => { + const actual = await vi.importActual('src/queries/profile/preferences'); + return { + ...actual, + usePreferences: queryMocks.usePreferences, + }; +}); + +queryMocks.usePreferences.mockReturnValue({ + data: preference, +}); describe('DeletionDialog', () => { const defaultArgs: DeletionDialogProps = { @@ -72,12 +91,21 @@ describe('DeletionDialog', () => { }); it('should call onDelete when the DeletionDialog delete button is clicked', () => { + queryMocks.usePreferences.mockReturnValue({ + data: preference, + }); const { getByTestId } = renderWithTheme( ); const deleteButton = getByTestId('confirm'); - expect(deleteButton).not.toBeDisabled(); + expect(deleteButton).toBeDisabled(); + + const input = getByTestId('textfield-input'); + fireEvent.change(input, { target: { value: defaultArgs.label } }); + + expect(deleteButton).toBeEnabled(); + fireEvent.click(deleteButton); expect(defaultArgs.onDelete).toHaveBeenCalled(); @@ -128,12 +156,12 @@ describe('DeletionDialog', () => { ])( 'should %s input field with label when typeToConfirm is %s', (_, typeToConfirm) => { + queryMocks.usePreferences.mockReturnValue({ + data: typeToConfirm, + }); + const { queryByTestId } = renderWithTheme( - + ); if (typeToConfirm) { diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx index 91ab330328f..efcedc42a6f 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx @@ -19,15 +19,8 @@ export interface DeletionDialogProps extends Omit { onClose: () => void; onDelete: () => void; open: boolean; - typeToConfirm?: boolean; } -/** - * A Deletion Dialog is used for deleting entities such as Linodes, NodeBalancers, Volumes, or other entities. - * - * Require `typeToConfirm` when an action would have a significant negative impact if done in error, consider requiring the user to enter a unique identifier such as entity label before activating the action button. - * If a user has opted out of type-to-confirm this will be ignored - */ export const DeletionDialog = React.memo((props: DeletionDialogProps) => { const theme = useTheme(); const { @@ -38,24 +31,21 @@ export const DeletionDialog = React.memo((props: DeletionDialogProps) => { onClose, onDelete, open, - typeToConfirm, ...rest } = props; const { data: typeToConfirmPreference } = usePreferences( - (preferences) => preferences?.type_to_confirm + (preferences) => preferences?.type_to_confirm ?? true ); const [confirmationText, setConfirmationText] = React.useState(''); - const typeToConfirmRequired = - typeToConfirm && typeToConfirmPreference !== false; - const renderActions = () => ( { label={`${capitalize(entity)} Name:`} placeholder={label} value={confirmationText} - visible={typeToConfirmRequired} + visible={Boolean(typeToConfirmPreference)} /> ); diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx index 961109e5ec9..3c3981192c0 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.test.tsx @@ -4,22 +4,62 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { TypeToConfirm } from './TypeToConfirm'; +import type { ManagerPreferences } from 'src/types/ManagerPreferences'; + const props = { onClick: vi.fn() }; +const preference: ManagerPreferences['type_to_confirm'] = true; + +const queryMocks = vi.hoisted(() => ({ + usePreferences: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/profile/preferences', async () => { + const actual = await vi.importActual('src/queries/profile/preferences'); + return { + ...actual, + usePreferences: queryMocks.usePreferences, + }; +}); + +queryMocks.usePreferences.mockReturnValue({ + data: preference, +}); + describe('TypeToConfirm Component', () => { const labelText = 'Label'; - it('Should have a label', () => { - const { getByText } = renderWithTheme( + it('Should not show label when visible prop not provided', () => { + const { queryByText } = renderWithTheme( ); + expect(queryByText(labelText)).not.toBeInTheDocument(); + }); + + it('Should not show input when visible prop not provided', () => { + const { queryByLabelText } = renderWithTheme( + + ); + expect(queryByLabelText(labelText)).not.toBeInTheDocument(); + }); + + it('Should display label when visible is true', () => { + queryMocks.usePreferences.mockReturnValue({ + data: preference, + }); + const { getByText } = renderWithTheme( + + ); const label = getByText(labelText); expect(label).toHaveTextContent(labelText); }); - it('Should have a text input field associated with label', () => { + it('Should display input when visible is true', () => { + queryMocks.usePreferences.mockReturnValue({ + data: preference, + }); const { getByLabelText } = renderWithTheme( - + ); const input = getByLabelText(labelText, { selector: 'input' }); expect(input).toBeInTheDocument(); diff --git a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx index 087b3f4b7d7..b170d97efa4 100644 --- a/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx +++ b/packages/manager/src/components/TypeToConfirm/TypeToConfirm.tsx @@ -3,10 +3,11 @@ import * as React from 'react'; import { FormGroup } from 'src/components/FormGroup'; import { Link } from 'src/components/Link'; +import { usePreferences } from 'src/queries/profile/preferences'; import type { TextFieldProps } from '@linode/ui'; -import type { Theme } from '@mui/material'; import type { SxProps } from '@mui/material'; +import type { Theme } from '@mui/material'; export interface TypeToConfirmProps extends Omit { confirmationText?: JSX.Element | string; @@ -36,18 +37,25 @@ export const TypeToConfirm = (props: TypeToConfirmProps) => { title, typographyStyle, typographyStyleSx, - visible, + visible = false, ...rest } = props; + const { data: typeToConfirmPreference } = usePreferences( + (preferences) => preferences?.type_to_confirm ?? true + ); + /* - There was an edge case bug where, when preferences?.type_to_confirm was undefined, - the type-to-confirm input did not appear and the language in the instruction text - did not match. If 'visible' is not explicitly false, we treat it as true. + There is an edge case where preferences?.type_to_confirm is undefined + when the user has not yet set a preference as seen in /profile/settings?preferenceEditor=true. + Therefore, the 'visible' prop defaults to true unless explicitly set to false, ensuring this feature is enabled by default. */ - const showTypeToConfirmInput = visible !== false; - const disableOrEnable = showTypeToConfirmInput ? 'disable' : 'enable'; + const showTypeToConfirmInput = Boolean(visible); + const disableOrEnable = + showTypeToConfirmInput || Boolean(typeToConfirmPreference) + ? 'disable' + : 'enable'; return ( <> diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 0363e1a685d..da512017444 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -128,12 +128,12 @@ export const TypeToConfirmDialog = (props: CombinedProps) => { }; const { data: typeToConfirmPreference } = usePreferences( - (preferences) => preferences?.type_to_confirm + (preferences) => preferences?.type_to_confirm ?? true ); const isCloseAccount = entity.subType === 'CloseAccount'; const isTypeToConfirmEnabled = - typeToConfirmPreference !== false || isCloseAccount; + Boolean(typeToConfirmPreference) || isCloseAccount; const isTextConfirmationValid = confirmationValues.confirmText === entity.name; const isCloseAccountValid = diff --git a/packages/manager/src/features/Domains/DeleteDomain.tsx b/packages/manager/src/features/Domains/DeleteDomain.tsx index a7b1aaa5a45..f3b0c88dff8 100644 --- a/packages/manager/src/features/Domains/DeleteDomain.tsx +++ b/packages/manager/src/features/Domains/DeleteDomain.tsx @@ -54,7 +54,6 @@ export const DeleteDomain = (props: DeleteDomainProps) => { onClose={() => setOpen(false)} onDelete={onDelete} open={open} - typeToConfirm /> ); diff --git a/packages/manager/src/features/Domains/DomainsLanding.tsx b/packages/manager/src/features/Domains/DomainsLanding.tsx index f96eb352ccb..81e8ac001b5 100644 --- a/packages/manager/src/features/Domains/DomainsLanding.tsx +++ b/packages/manager/src/features/Domains/DomainsLanding.tsx @@ -360,7 +360,6 @@ export const DomainsLanding = (props: DomainsLandingProps) => { onClose={navigateToDomains} onDelete={removeDomain} open={params.action === 'delete'} - typeToConfirm /> ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx index 028cd43ac44..b7aa6f58977 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx @@ -84,7 +84,7 @@ export const RebuildFromImage = (props: Props) => { const { data: typeToConfirmPreference, isLoading: isLoadingPreferences, - } = usePreferences((preferences) => preferences?.type_to_confirm); + } = usePreferences((preferences) => preferences?.type_to_confirm ?? true); const { checkForNewEvents } = useEventsPollingActions(); @@ -126,7 +126,7 @@ export const RebuildFromImage = (props: Props) => { }, [shouldReuseUserData]); const submitButtonDisabled = - typeToConfirmPreference !== false && confirmationText !== linodeLabel; + Boolean(typeToConfirmPreference) && confirmationText !== linodeLabel; const handleFormSubmit = ( { authorized_users, image, root_pass }: RebuildFromImageForm, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx index 33667629d19..6327885afad 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx @@ -86,7 +86,7 @@ export const RebuildFromStackScript = (props: Props) => { const { data: typeToConfirmPreference, isLoading: isLoadingPreferences, - } = usePreferences((preferences) => preferences?.type_to_confirm); + } = usePreferences((preferences) => preferences?.type_to_confirm ?? true); const { checkForNewEvents } = useEventsPollingActions(); @@ -107,7 +107,7 @@ export const RebuildFromStackScript = (props: Props) => { const [confirmationText, setConfirmationText] = React.useState(''); const submitButtonDisabled = - typeToConfirmPreference !== false && confirmationText !== linodeLabel; + Boolean(typeToConfirmPreference) && confirmationText !== linodeLabel; const [ ss, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index 9a38f9ff28e..1468cace19c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -74,7 +74,7 @@ export const LinodeResize = (props: Props) => { const { data: types } = useAllTypes(open); const { data: typeToConfirmPreference } = usePreferences( - (preferences) => preferences?.type_to_confirm, + (preferences) => preferences?.type_to_confirm ?? true, open ); @@ -167,7 +167,7 @@ export const LinodeResize = (props: Props) => { const tableDisabled = hostMaintenance || isLinodesGrantReadOnly; const submitButtonDisabled = - typeToConfirmPreference !== false && confirmationText !== linode?.label; + Boolean(typeToConfirmPreference) && confirmationText !== linode?.label; const type = types?.find((t) => t.id === linode?.type); diff --git a/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx b/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx index 0d9eae6e1aa..1d1754dfc70 100644 --- a/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx +++ b/packages/manager/src/features/Profile/Settings/TypeToConfirm.tsx @@ -7,17 +7,13 @@ import { } from 'src/queries/profile/preferences'; export const TypeToConfirm = () => { + // Type-to-confirm is enabled by default when no preference is set. const { data: typeToConfirmPreference, isLoading } = usePreferences( - (preferences) => preferences?.type_to_confirm + (preferences) => preferences?.type_to_confirm ?? true ); const { mutateAsync: updatePreferences } = useMutatePreferences(); - // Type-to-confirm is enabled by default (no preference is set) - // or if the user explicitly enables it. - const isTypeToConfirmEnabled = - typeToConfirmPreference === undefined || typeToConfirmPreference === true; - return ( @@ -33,11 +29,11 @@ export const TypeToConfirm = () => { onChange={(_, checked) => updatePreferences({ type_to_confirm: checked }) } - checked={isTypeToConfirmEnabled} + checked={typeToConfirmPreference} /> } label={`Type-to-confirm is ${ - isTypeToConfirmEnabled ? 'enabled' : 'disabled' + typeToConfirmPreference ? 'enabled' : 'disabled' }`} disabled={isLoading} /> From 73f91860d773e5b975a8eb0814c13039ed8c4013 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 10 Jan 2025 15:36:00 +0530 Subject: [PATCH 04/42] refactor: [M3-8246, M3-8252] - Replace Ramda's `splitAt` with custom utility (#11483) * Add custom utility and remove `splitAt` ramda instances * Cleanup with method overloading * Fix comments * Added changeset: Replace ramda's `splitAt` with custom utility * Fix comment --- .../pr-11483-tech-stories-1736333190430.md | 5 ++ packages/manager/src/components/OrderBy.tsx | 3 +- packages/manager/src/components/Tags/Tags.tsx | 2 +- .../src/features/Domains/DomainActionMenu.tsx | 11 ++- .../LinodeConfigs/LinodeConfigActionMenu.tsx | 2 +- .../LinodeStorage/LinodeDiskActionMenu.tsx | 2 +- .../Managed/Monitors/MonitorActionMenu.tsx | 10 ++- .../src/features/Search/ResultGroup.tsx | 6 +- .../manager/src/utilities/splitAt.test.ts | 78 +++++++++++++++++++ packages/manager/src/utilities/splitAt.ts | 18 +++++ 10 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-11483-tech-stories-1736333190430.md create mode 100644 packages/manager/src/utilities/splitAt.test.ts create mode 100644 packages/manager/src/utilities/splitAt.ts diff --git a/packages/manager/.changeset/pr-11483-tech-stories-1736333190430.md b/packages/manager/.changeset/pr-11483-tech-stories-1736333190430.md new file mode 100644 index 00000000000..bca4473cd5e --- /dev/null +++ b/packages/manager/.changeset/pr-11483-tech-stories-1736333190430.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace ramda's `splitAt` with custom utility ([#11483](https://github.com/linode/manager/pull/11483)) diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index 9ffcba5454a..82b5a2a9801 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import { equals, pathOr, sort, splitAt } from 'ramda'; +import { equals, pathOr, sort } from 'ramda'; import * as React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; @@ -16,6 +16,7 @@ import { sortByString, sortByUTFDate, } from 'src/utilities/sort-by'; +import { splitAt } from 'src/utilities/splitAt'; import type { Order } from 'src/hooks/useOrder'; import type { ManagerPreferences } from 'src/types/ManagerPreferences'; diff --git a/packages/manager/src/components/Tags/Tags.tsx b/packages/manager/src/components/Tags/Tags.tsx index 970e750d9e5..c7cbc70a326 100644 --- a/packages/manager/src/components/Tags/Tags.tsx +++ b/packages/manager/src/components/Tags/Tags.tsx @@ -1,8 +1,8 @@ -import { splitAt } from 'ramda'; import * as React from 'react'; import { ShowMore } from 'src/components/ShowMore/ShowMore'; import { Tag } from 'src/components/Tag/Tag'; +import { splitAt } from 'src/utilities/splitAt'; export interface TagsProps { /** diff --git a/packages/manager/src/features/Domains/DomainActionMenu.tsx b/packages/manager/src/features/Domains/DomainActionMenu.tsx index 6347df03205..4ad64e42d90 100644 --- a/packages/manager/src/features/Domains/DomainActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainActionMenu.tsx @@ -1,12 +1,15 @@ -import { Domain } from '@linode/api-v4/lib/domains'; -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { splitAt } from 'ramda'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { splitAt } from 'src/utilities/splitAt'; + +import type { Domain } from '@linode/api-v4/lib/domains'; +import type { Theme } from '@mui/material/styles'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; const useStyles = makeStyles()(() => ({ button: { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx index 2595837ce31..beaaa0ebf28 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx @@ -1,12 +1,12 @@ import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { splitAt } from 'src/utilities/splitAt'; import type { Config } from '@linode/api-v4/lib/linodes'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx index c42beb7814e..bb730f48bbf 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx @@ -1,13 +1,13 @@ import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { sendEvent } from 'src/utilities/analytics/utils'; +import { splitAt } from 'src/utilities/splitAt'; import type { Disk, Linode } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx b/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx index cb23a01f830..6990e6475ac 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx @@ -1,18 +1,20 @@ -import { MonitorStatus } from '@linode/api-v4/lib/managed'; -import { APIError } from '@linode/api-v4/lib/types'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useSnackbar } from 'notistack'; -import { splitAt } from 'ramda'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { useDisableMonitorMutation, useEnableMonitorMutation, } from 'src/queries/managed/managed'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { splitAt } from 'src/utilities/splitAt'; + +import type { MonitorStatus } from '@linode/api-v4/lib/managed'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface MonitorActionMenuProps { label: string; diff --git a/packages/manager/src/features/Search/ResultGroup.tsx b/packages/manager/src/features/Search/ResultGroup.tsx index 3a946e6ab20..a2f947c6790 100644 --- a/packages/manager/src/features/Search/ResultGroup.tsx +++ b/packages/manager/src/features/Search/ResultGroup.tsx @@ -1,8 +1,7 @@ import Grid from '@mui/material/Unstable_Grid2'; -import { isEmpty, splitAt } from 'ramda'; +import { isEmpty } from 'ramda'; import * as React from 'react'; -import { Item } from 'src/components/EnhancedSelect/Select'; import { Hidden } from 'src/components/Hidden'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; @@ -10,10 +9,13 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { capitalize } from 'src/utilities/capitalize'; +import { splitAt } from 'src/utilities/splitAt'; import { StyledButton, StyledTypography } from './ResultGroup.styles'; import { ResultRow } from './ResultRow'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + interface ResultGroupProps { entity: string; groupSize: number; diff --git a/packages/manager/src/utilities/splitAt.test.ts b/packages/manager/src/utilities/splitAt.test.ts new file mode 100644 index 00000000000..65acb0bb2fc --- /dev/null +++ b/packages/manager/src/utilities/splitAt.test.ts @@ -0,0 +1,78 @@ +import { splitAt } from './splitAt'; + +describe('splitAt', () => { + // For arrays + it('splits an array at the given index', () => { + const result = splitAt(3, [1, 2, 3, 4, 5]); + expect(result).toEqual([ + [1, 2, 3], + [4, 5], + ]); + }); + + it('splits an array when index is 0', () => { + const result = splitAt(0, [1, 2, 3, 4, 5]); + expect(result).toEqual([[], [1, 2, 3, 4, 5]]); + }); + + it('splits an array when index is the length of the array', () => { + const result = splitAt(5, [1, 2, 3, 4, 5]); + expect(result).toEqual([[1, 2, 3, 4, 5], []]); + }); + + it('splits an array when index is the (length - 1) of the array', () => { + const result = splitAt(4, [1, 2, 3, 4, 5]); + expect(result).toEqual([[1, 2, 3, 4], [5]]); + }); + + it('splits an empty array', () => { + const result = splitAt(0, []); + expect(result).toEqual([[], []]); + }); + + it('splits an array of one element', () => { + const result = splitAt(1, [1]); + expect(result).toEqual([[1], []]); + }); + + it('splits an array at the given negative index', () => { + const result = splitAt(-1, [1, 2, 3, 4, 5]); + expect(result).toEqual([[1, 2, 3, 4], [5]]); + }); + + // For strings + it('splits a string at the given index', () => { + const result = splitAt(3, 'abcdefgh'); + expect(result).toEqual(['abc', 'defgh']); + }); + + it('splits a string when index is 0', () => { + const result = splitAt(0, 'abcdefgh'); + expect(result).toEqual(['', 'abcdefgh']); + }); + + it('splits a string when index is the length of the string', () => { + const result = splitAt(8, 'abcdefgh'); + expect(result).toEqual(['abcdefgh', '']); + }); + + it('splits a string when index is the (length - 1) of the string', () => { + const result = splitAt(7, 'abcdefgh'); + expect(result).toEqual(['abcdefg', 'h']); + }); + + it('splits an empty string', () => { + const result = splitAt(0, ''); + expect(result).toEqual(['', '']); + }); + + it('splits a string with one character', () => { + const result = splitAt(1, 'a'); + expect(result).toEqual(['a', '']); + }); + + it('splits a string at the given negative index', () => { + const result = splitAt(-1, 'abcdefgh'); + expect(result).toEqual(['abcdefg', 'h']); + }); +}); diff --git a/packages/manager/src/utilities/splitAt.ts b/packages/manager/src/utilities/splitAt.ts new file mode 100644 index 00000000000..c88618533a1 --- /dev/null +++ b/packages/manager/src/utilities/splitAt.ts @@ -0,0 +1,18 @@ +export function splitAt(index: number, input: T[]): [T[], T[]]; + +export function splitAt(index: number, input: string): [string, string]; + +/** + * Splits a given list or string at a given index. + * + * @param index - The index to split at. + * @param input - The list (array) or string to split. + * @returns An array containing two parts: the first part (0 to index), the second part (index to end). + * + * @example + * splitAt(3, [1, 2, 3, 4, 5]); // [[1, 2, 3], [4, 5]] + * splitAt(3, "hello"); // ["hel", "lo"] + */ +export function splitAt(index: number, input: T[] | string) { + return [input.slice(0, index), input.slice(index)]; +} From 0532aad7324d966417d1976b263b8f507cf9f9aa Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:47:55 -0500 Subject: [PATCH 05/42] test: [M3-8313] - Move OBJ Multicluster Cypress tests to `ObjectStorageMulticluster` (#11484) * Move OBJ multicluster tests from `objectStorage` to `objectStorageMulticluster` dir * Added changeset: Improve organization of Object Storage and Object Storage Multicluster tests --- .../pr-11484-tests-1736279319564.md | 5 + .../objectStorage/access-keys.smoke.spec.ts | 402 +---------------- .../objectStorage/object-storage.e2e.spec.ts | 310 +------------ .../object-storage.smoke.spec.ts | 183 +------- .../access-keys-multicluster.spec.ts | 406 ++++++++++++++++++ .../bucket-create-multicluster.spec.ts | 138 ++++++ .../bucket-delete-multicluster.spec.ts | 65 +++ .../bucket-details-multicluster.spec.ts} | 16 +- ...bject-storage-objects-multicluster.spec.ts | 327 ++++++++++++++ 9 files changed, 961 insertions(+), 891 deletions(-) create mode 100644 packages/manager/.changeset/pr-11484-tests-1736279319564.md create mode 100644 packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts create mode 100644 packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts create mode 100644 packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts rename packages/manager/cypress/e2e/core/{objectStorage/bucket-details.spec.ts => objectStorageMulticluster/bucket-details-multicluster.spec.ts} (75%) create mode 100644 packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts diff --git a/packages/manager/.changeset/pr-11484-tests-1736279319564.md b/packages/manager/.changeset/pr-11484-tests-1736279319564.md new file mode 100644 index 00000000000..32bd8c34592 --- /dev/null +++ b/packages/manager/.changeset/pr-11484-tests-1736279319564.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve organization of Object Storage and Object Storage Multicluster tests ([#11484](https://github.com/linode/manager/pull/11484)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 8109717608d..05eb396a5d3 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -2,31 +2,17 @@ * @file Smoke tests for crucial Object Storage Access Keys operations. */ -import { - objectStorageKeyFactory, - objectStorageBucketFactory, -} from 'src/factories/objectStorage'; +import { objectStorageKeyFactory } from 'src/factories/objectStorage'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateAccessKey, mockDeleteAccessKey, mockGetAccessKeys, - mockGetBucketsForRegion, - mockUpdateAccessKey, } from 'support/intercepts/object-storage'; -import { - randomDomainName, - randomLabel, - randomNumber, - randomString, -} from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { accountFactory, regionFactory } from 'src/factories'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { buildArray } from 'support/util/arrays'; -import { ObjectStorageKeyBucketAccess } from '@linode/api-v4'; +import { accountFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import { extendRegion } from 'support/util/regions'; describe('object storage access keys smoke tests', () => { /* @@ -147,386 +133,4 @@ describe('object storage access keys smoke tests', () => { cy.wait(['@deleteKey', '@getKeys']); cy.findByText('No items to display.').should('be.visible'); }); - - describe('Object Storage Multicluster feature enabled', () => { - const mockRegionsObj = buildArray(3, () => { - return extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - }); - - const mockRegions = [...mockRegionsObj]; - - beforeEach(() => { - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }); - }); - - /* - * - Confirms user can create access keys with unlimited access when OBJ Multicluster is enabled. - * - Confirms multiple regions can be selected when creating an access key. - * - Confirms that UI updates to reflect created access key. - */ - it('can create unlimited access keys with OBJ Multicluster', () => { - const mockAccessKey = objectStorageKeyFactory.build({ - id: randomNumber(10000, 99999), - label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), - regions: mockRegionsObj.map((mockObjRegion) => ({ - id: mockObjRegion.id, - s3_endpoint: randomDomainName(), - })), - }); - - mockGetAccessKeys([]); - mockCreateAccessKey(mockAccessKey).as('createAccessKey'); - mockGetRegions(mockRegions); - mockRegions.forEach((region) => { - mockGetBucketsForRegion(region.id, []); - }); - - cy.visitWithLogin('/object-storage/access-keys'); - - ui.button - .findByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - - mockGetAccessKeys([mockAccessKey]); - ui.drawer - .findByTitle('Create Access Key') - .should('be.visible') - .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type(mockAccessKey.label); - - cy.contains('Regions (required)').should('be.visible').click(); - - // Select each region with the OBJ capability. - mockRegionsObj.forEach((mockRegion) => { - cy.contains('Regions (required)').type(mockRegion.label); - ui.autocompletePopper - .findByTitle(`${mockRegion.label} (${mockRegion.id})`) - .should('be.visible') - .click(); - }); - - // Close the regions drop-down. - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type('{esc}'); - - // TODO Confirm expected regions are shown. - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@createAccessKey'); - ui.dialog - .findByTitle('Access Keys') - .should('be.visible') - .within(() => { - // TODO Add assertions for S3 hostnames - cy.get('input[id="access-key"]') - .should('be.visible') - .should('have.value', mockAccessKey.access_key); - cy.get('input[id="secret-key"]') - .should('be.visible') - .should('have.value', mockAccessKey.secret_key); - - ui.button - .findByTitle('I Have Saved My Secret Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.findByText(mockAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - // TODO Add assertions for regions/S3 hostnames - cy.findByText(mockAccessKey.access_key).should('be.visible'); - }); - }); - - /* - * - COnfirms user can create access keys with limited access when OBJ Multicluster is enabled. - * - Confirms that UI updates to reflect created access key. - * - Confirms that "Permissions" drawer contains expected scope and permission data. - */ - it('can create limited access keys with OBJ Multicluster', () => { - const mockRegion = extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockBuckets = objectStorageBucketFactory.buildList(2, { - region: mockRegion.id, - cluster: undefined, - }); - - const mockAccessKey = objectStorageKeyFactory.build({ - id: randomNumber(10000, 99999), - label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), - regions: [ - { - id: mockRegion.id, - s3_endpoint: randomDomainName(), - }, - ], - limited: true, - bucket_access: mockBuckets.map( - (bucket): ObjectStorageKeyBucketAccess => ({ - bucket_name: bucket.label, - cluster: '', - permissions: 'read_only', - region: mockRegion.id, - }) - ), - }); - - mockGetAccessKeys([]); - mockCreateAccessKey(mockAccessKey).as('createAccessKey'); - mockGetRegions([mockRegion]); - mockGetBucketsForRegion(mockRegion.id, mockBuckets); - - // Navigate to access keys page, click "Create Access Key" button. - cy.visitWithLogin('/object-storage/access-keys'); - ui.button - .findByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - - // Fill out form in "Create Access Key" drawer. - ui.drawer - .findByTitle('Create Access Key') - .should('be.visible') - .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type(mockAccessKey.label); - - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type(`${mockRegion.label}{enter}`); - - ui.autocompletePopper - .findByTitle(`${mockRegion.label} (${mockRegion.id})`) - .should('be.visible'); - - // Dismiss region drop-down. - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type('{esc}'); - - // Enable "Limited Access" toggle for access key and confirm Create button is disabled. - cy.findByText('Limited Access').should('be.visible').click(); - - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.disabled'); - - // Select access rules for all buckets to enable Create button. - mockBuckets.forEach((mockBucket) => { - cy.findByText(mockBucket.label) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByLabelText( - `read-only for ${mockRegion.id}-${mockBucket.label}` - ) - .should('be.enabled') - .click(); - }); - }); - - mockGetAccessKeys([mockAccessKey]); - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.enabled') - .click(); - }); - - // Dismiss secrets dialog. - cy.wait('@createAccessKey'); - ui.buttonGroup - .findButtonByTitle('I Have Saved My Secret Key') - .should('be.visible') - .should('be.enabled') - .click(); - - // Open "Permissions" drawer for new access key. - cy.findByText(mockAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle( - `Action menu for Object Storage Key ${mockAccessKey.label}` - ) - .should('be.visible') - .click(); - }); - - ui.actionMenuItem.findByTitle('Permissions').click(); - ui.drawer - .findByTitle(`Permissions for ${mockAccessKey.label}`) - .should('be.visible') - .within(() => { - mockBuckets.forEach((mockBucket) => { - // TODO M3-7733 Update this selector when ARIA label is fixed. - cy.findByLabelText( - `This token has read-only access for ${mockRegion.id}-${mockBucket.label}` - ); - }); - }); - }); - - /* - * - Confirms user can edit access key labels and regions when OBJ Multicluster is enabled. - * - Confirms that user can deselect regions via the region selection list. - * - Confirms that access keys landing page automatically updates to reflect edited access key. - */ - it('can update access keys with OBJ Multicluster', () => { - const mockInitialRegion = extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockUpdatedRegion = extendRegion( - regionFactory.build({ - id: `us-${randomString(5)}`, - label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockRegions = [mockInitialRegion, mockUpdatedRegion]; - - const mockAccessKey = objectStorageKeyFactory.build({ - id: randomNumber(10000, 99999), - label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), - regions: [ - { - id: mockInitialRegion.id, - s3_endpoint: randomDomainName(), - }, - ], - }); - - const mockUpdatedAccessKeyEndpoint = randomDomainName(); - - const mockUpdatedAccessKey = { - ...mockAccessKey, - label: randomLabel(), - regions: [ - { - id: mockUpdatedRegion.id, - s3_endpoint: mockUpdatedAccessKeyEndpoint, - }, - ], - }; - - mockGetAccessKeys([mockAccessKey]); - mockGetRegions(mockRegions); - cy.visitWithLogin('/object-storage/access-keys'); - - cy.findByText(mockAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle( - `Action menu for Object Storage Key ${mockAccessKey.label}` - ) - .should('be.visible') - .click(); - }); - - ui.actionMenuItem - .findByTitle('Edit') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.drawer - .findByTitle('Edit Access Key') - .should('be.visible') - .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type('{selectall}{backspace}') - .type(mockUpdatedAccessKey.label); - - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type(`${mockUpdatedRegion.label}{enter}{esc}`); - - cy.contains(mockUpdatedRegion.label) - .should('be.visible') - .and('exist'); - - // Directly find the close button within the chip - cy.findByTestId(`${mockUpdatedRegion.id}`) - .findByTestId('CloseIcon') - .click(); - - mockUpdateAccessKey(mockUpdatedAccessKey).as('updateAccessKey'); - mockGetAccessKeys([mockUpdatedAccessKey]); - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateAccessKey'); - - // Confirm that access key landing page reflects updated key. - cy.findByText(mockAccessKey.label).should('not.exist'); - cy.findByText(mockUpdatedAccessKey.label) - .should('be.visible') - .closest('tr') - .within(() => { - cy.contains(mockUpdatedRegion.label).should('be.visible'); - cy.contains(mockUpdatedAccessKeyEndpoint).should('be.visible'); - }); - }); - }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index f9c431f5a30..1fa96cb91c1 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -2,12 +2,10 @@ * @file End-to-end tests for Object Storage operations. */ -import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; import { accountFactory, createObjectStorageBucketFactoryLegacy, - createObjectStorageBucketFactoryGen1, } from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { @@ -18,7 +16,6 @@ import { interceptCreateBucket, interceptDeleteBucket, interceptGetBuckets, - interceptUploadBucketObjectS3, interceptGetBucketAccess, interceptUpdateBucketAccess, } from 'support/intercepts/object-storage'; @@ -27,26 +24,6 @@ import { randomLabel } from 'support/util/random'; import { cleanUp } from 'support/util/cleanup'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -// Message shown on-screen when user navigates to an empty bucket. -const emptyBucketMessage = 'This bucket is empty.'; - -// Message shown on-screen when user navigates to an empty folder. -const emptyFolderMessage = 'This folder is empty.'; - -/** - * Returns the non-empty bucket error message for a bucket with the given label. - * - * This message appears when attempting to delete a bucket that has one or - * more objects. - * - * @param bucketLabel - Label of bucket being deleted. - * - * @returns Non-empty bucket error message. - */ -const getNonEmptyBucketMessage = (bucketLabel: string) => { - return `Bucket ${bucketLabel} is not empty. Please delete all objects and try again.`; -}; - /** * Create a bucket with the given label and cluster. * @@ -78,82 +55,6 @@ const setUpBucket = ( ); }; -/** - * Create a bucket with the given label and cluster. - * - * This function assumes that OBJ Multicluster is enabled. Use - * `setUpBucket` to set up OBJ buckets when Multicluster is disabled. - * - * @param label - Bucket label. - * @param regionId - ID of Bucket region. - * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. - * - * @returns Promise that resolves to created Bucket. - */ -const setUpBucketMulticluster = ( - label: string, - regionId: string, - cors_enabled: boolean = true -) => { - return createBucket( - createObjectStorageBucketFactoryGen1.build({ - label, - region: regionId, - cors_enabled, - - // API accepts either `cluster` or `region`, but not both. Our factory - // populates both fields, so we have to manually set `cluster` to `undefined` - // to avoid 400 responses from the API. - cluster: undefined, - }) - ); -}; - -/** - * Uploads the file at the given path and assigns it the given filename. - * - * This assumes that Cypress has already navigated to a page where a file - * upload prompt is present. - * - * @param filepath - Path to file to upload. - * @param filename - Filename to assign to uploaded file. - */ -const uploadFile = (filepath: string, filename: string) => { - cy.fixture(filepath, null).then((contents) => { - cy.get('[data-qa-drop-zone]').attachFile( - { - fileContent: contents, - fileName: filename, - }, - { - subjectType: 'drag-n-drop', - } - ); - }); -}; - -/** - * Asserts that a URL assigned to an alias responds with a given status code. - * - * @param urlAlias - Cypress alias containing the URL to request. - * @param expectedStatus - HTTP status to expect for URL. - */ -const assertStatusForUrlAtAlias = ( - urlAlias: string, - expectedStatus: number -) => { - cy.get(urlAlias).then((url: unknown) => { - // An alias can resolve to anything. We're assuming the user passed a valid - // alias which resolves to a string. - cy.request({ - url: url as string, - failOnStatusCode: false, - }).then((response) => { - expect(response.status).to.eq(expectedStatus); - }); - }); -}; - authenticate(); beforeEach(() => { cy.tag('method:e2e'); @@ -245,216 +146,10 @@ describe('object storage end-to-end tests', () => { cy.findByText(bucketLabel).should('not.exist'); }); - /* - * - Confirms that users can upload new objects. - * - Confirms that users can replace objects with identical filenames. - * - Confirms that users can delete objects. - * - Confirms that users can create folders. - * - Confirms that users can delete empty folders. - * - Confirms that users cannot delete folders with objects. - * - Confirms that users cannot delete buckets with objects. - * - Confirms that private objects cannot be accessed over HTTP. - * - Confirms that public objects can be accessed over HTTP. - */ - it('can upload, access, and delete objects', () => { - const bucketLabel = randomLabel(); - const bucketCluster = 'us-southeast-1'; - const bucketRegionId = 'us-southeast'; - const bucketPage = `/object-storage/buckets/${bucketRegionId}/${bucketLabel}/objects`; - const bucketFolderName = randomLabel(); - - const bucketFiles = [ - { path: 'object-storage-files/1.txt', name: '1.txt' }, - { path: 'object-storage-files/2.jpg', name: '2.jpg' }, - ]; - - cy.defer( - () => setUpBucketMulticluster(bucketLabel, bucketRegionId), - 'creating Object Storage bucket' - ).then(() => { - interceptUploadBucketObjectS3( - bucketLabel, - bucketCluster, - bucketFiles[0].name - ).as('uploadObject'); - - // Navigate to new bucket page, upload and delete an object. - cy.visitWithLogin(bucketPage); - ui.entityHeader.find().within(() => { - cy.findByText(bucketLabel).should('be.visible'); - }); - - uploadFile(bucketFiles[0].path, bucketFiles[0].name); - - // @TODO Investigate why files do not appear automatically in Cypress. - cy.wait('@uploadObject'); - cy.reload(); - - cy.findByText(bucketFiles[0].name).should('be.visible'); - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFiles[0].name}`) - .should('be.visible') - .within(() => { - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .click(); - }); - - cy.findByText(emptyBucketMessage).should('be.visible'); - cy.findByText(bucketFiles[0].name).should('not.exist'); - - // Create a folder, navigate into it and upload object. - ui.button.findByTitle('Create Folder').should('be.visible').click(); - - ui.drawer - .findByTitle('Create Folder') - .should('be.visible') - .within(() => { - cy.findByLabelText('Folder Name') - .should('be.visible') - .click() - .type(bucketFolderName); - - ui.buttonGroup - .findButtonByTitle('Create') - .should('be.visible') - .click(); - }); - - cy.findByText(bucketFolderName).should('be.visible').click(); - - cy.findByText(emptyFolderMessage).should('be.visible'); - interceptUploadBucketObjectS3( - bucketLabel, - bucketCluster, - `${bucketFolderName}/${bucketFiles[1].name}` - ).as('uploadObject'); - uploadFile(bucketFiles[1].path, bucketFiles[1].name); - cy.wait('@uploadObject'); - - // Re-upload file to confirm replace prompt behavior. - uploadFile(bucketFiles[1].path, bucketFiles[1].name); - cy.findByText( - 'This file already exists. Are you sure you want to overwrite it?' - ); - ui.button.findByTitle('Replace').should('be.visible').click(); - cy.wait('@uploadObject'); - - // Confirm that you cannot delete a bucket with objects in it. - cy.visitWithLogin('/object-storage/buckets'); - cy.findByText(bucketLabel) - .should('be.visible') - .closest('tr') - .within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - }); - - ui.dialog - .findByTitle(`Delete Bucket ${bucketLabel}`) - .should('be.visible') - .within(() => { - cy.findByText('Bucket Name').click().type(bucketLabel); - - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.findByText(getNonEmptyBucketMessage(bucketLabel)).should( - 'be.visible' - ); - }); - - // Confirm that you cannot delete a folder with objects in it. - cy.visitWithLogin(bucketPage); - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFolderName}`) - .should('be.visible') - .within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - - cy.findByText('The folder must be empty to delete it.').should( - 'be.visible' - ); - - ui.button.findByTitle('Cancel').should('be.visible').click(); - }); - - // Confirm public/private access controls work as expected. - cy.findByText(bucketFolderName).should('be.visible').click(); - cy.findByText(bucketFiles[1].name).should('be.visible').click(); - - ui.drawer - .findByTitle(`${bucketFolderName}/${bucketFiles[1].name}`) - .should('be.visible') - .within(() => { - // Confirm that object is not public by default. - cy.get('[data-testid="external-site-link"]') - .should('be.visible') - .invoke('attr', 'href') - .as('bucketObjectUrl'); - - assertStatusForUrlAtAlias('@bucketObjectUrl', 403); - - // Make object public, confirm it can be accessed, then close drawer. - cy.findByLabelText('Access Control List (ACL)') - .should('be.visible') - .should('not.have.value', 'Loading access...') - .should('have.value', 'Private') - .click() - .type('Public Read'); - - ui.autocompletePopper - .findByTitle('Public Read') - .should('be.visible') - .click(); - - ui.button.findByTitle('Save').should('be.visible').click(); - - cy.findByText('Object access updated successfully.'); - assertStatusForUrlAtAlias('@bucketObjectUrl', 200); - - ui.drawerCloseButton.find().should('be.visible').click(); - }); - - // Delete object, then delete folder that contained the object. - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFiles[1].name}`) - .should('be.visible') - .within(() => { - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .click(); - }); - - cy.findByText(emptyFolderMessage).should('be.visible'); - - cy.visitWithLogin(bucketPage); - ui.button.findByTitle('Delete').should('be.visible').click(); - - ui.dialog - .findByTitle(`Delete ${bucketFolderName}`) - .should('be.visible') - .within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - }); - - // Confirm that bucket is empty. - cy.findByText(emptyBucketMessage).should('be.visible'); - }); - }); - /* * - Confirms that user can update Bucket access. + * - Confirms user can switch bucket access from Private to Public Read. + * - Confirms that toast notification appears confirming operation. */ it('can update bucket access', () => { const bucketLabel = randomLabel(); @@ -493,6 +188,7 @@ describe('object storage end-to-end tests', () => { ui.button.findByTitle('Save').should('be.visible').click(); + // TODO Confirm that outgoing API request contains expected values. cy.wait('@updateBucketAccess'); cy.findByText('Bucket access updated successfully.'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index ec96b743c0b..479bd129fbb 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -4,7 +4,6 @@ import 'cypress-file-upload'; import { objectStorageBucketFactory } from 'src/factories/objectStorage'; -import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateBucket, mockDeleteBucket, @@ -14,136 +13,14 @@ import { mockGetBucketObjects, mockUploadBucketObject, mockUploadBucketObjectS3, - mockCreateBucketError, } from 'support/intercepts/object-storage'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; -import { accountFactory, regionFactory } from 'src/factories'; +import { accountFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import { extendRegion } from 'support/util/regions'; describe('object storage smoke tests', () => { - /* - * - Tests Object Storage bucket creation flow when OBJ Multicluster is enabled. - * - Confirms that expected regions are displayed in drop-down. - * - Confirms that region can be selected during create. - * - Confirms that API errors are handled gracefully by drawer. - * - Confirms that request payload contains desired Bucket region and not cluster. - * - Confirms that created Bucket is listed on the landing page. - */ - it('can create object storage bucket with OBJ Multicluster', () => { - const mockErrorMessage = 'An unknown error has occurred.'; - - const mockRegionWithObj = extendRegion( - regionFactory.build({ - label: randomLabel(), - id: `${randomString(2)}-${randomString(3)}`, - capabilities: ['Object Storage'], - }) - ); - - const mockRegionsWithoutObj = regionFactory - .buildList(2, { - capabilities: [], - }) - .map((region) => extendRegion(region)); - - const mockRegions = [mockRegionWithObj, ...mockRegionsWithoutObj]; - - const mockBucket = objectStorageBucketFactory.build({ - label: randomLabel(), - region: mockRegionWithObj.id, - cluster: undefined, - objects: 0, - }); - - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }).as('getFeatureFlags'); - - mockGetRegions(mockRegions).as('getRegions'); - mockGetBuckets([]).as('getBuckets'); - mockCreateBucketError(mockErrorMessage).as('createBucket'); - - cy.visitWithLogin('/object-storage'); - cy.wait(['@getRegions', '@getBuckets']); - - ui.entityHeader.find().within(() => { - ui.button.findByTitle('Create Bucket').should('be.visible').click(); - }); - - ui.drawer - .findByTitle('Create Bucket') - .should('be.visible') - .within(() => { - // Enter label. - cy.contains('Label').click().type(mockBucket.label); - cy.log(`${mockRegionWithObj.label}`); - cy.contains('Region').click().type(mockRegionWithObj.label); - - ui.autocompletePopper - .find() - .should('be.visible') - .within(() => { - // Confirm that regions without 'Object Storage' capability are not listed. - mockRegionsWithoutObj.forEach((mockRegionWithoutObj) => { - cy.contains(mockRegionWithoutObj.id).should('not.exist'); - }); - - // Confirm that region with 'Object Storage' capability is listed, - // then select it. - cy.findByText( - `${mockRegionWithObj.label} (${mockRegionWithObj.id})` - ) - .should('be.visible') - .click(); - }); - - // Close region select. - cy.contains('Region').click(); - - // On first attempt, mock an error response and confirm message is shown. - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .click(); - - cy.wait('@createBucket'); - cy.findByText(mockErrorMessage).should('be.visible'); - - // Click submit again, mock a successful response. - mockCreateBucket(mockBucket).as('createBucket'); - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .click(); - }); - - // Confirm that Cloud includes the "region" property and omits the "cluster" - // property in its payload when creating a bucket. - cy.wait('@createBucket').then((xhr) => { - const body = xhr.request.body; - expect(body.cluster).to.be.undefined; - expect(body.region).to.eq(mockRegionWithObj.id); - }); - - cy.findByText(mockBucket.label) - .should('be.visible') - .closest('tr') - .within(() => { - // TODO Confirm that bucket region is shown in landing page. - cy.findByText(mockBucket.hostname).should('be.visible'); - // cy.findByText(mockRegionWithObj.label).should('be.visible'); - }); - }); - /* * - Tests core object storage bucket create flow using mocked API responses. * - Creates bucket. @@ -289,7 +166,7 @@ describe('object storage smoke tests', () => { * - Mocks existing buckets. * - Deletes mocked bucket, confirms that landing page reflects deletion. */ - it('can delete object storage bucket - smoke - Multi Cluster Disabled', () => { + it('can delete object storage bucket - smoke', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ @@ -333,58 +210,4 @@ describe('object storage smoke tests', () => { cy.wait('@deleteBucket'); cy.findByText('S3-compatible storage solution').should('be.visible'); }); - - /* - * - Tests core object storage bucket deletion flow using mocked API responses. - * - Mocks existing buckets. - * - Deletes mocked bucket, confirms that landing page reflects deletion. - */ - it('can delete object storage bucket - smoke - Multi Cluster Enabled', () => { - const bucketLabel = randomLabel(); - const bucketCluster = 'us-southeast-1'; - const bucketMock = objectStorageBucketFactory.build({ - label: bucketLabel, - cluster: bucketCluster, - hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, - objects: 0, - }); - - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }); - - mockGetBuckets([bucketMock]).as('getBuckets'); - mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); - - cy.visitWithLogin('/object-storage'); - cy.wait('@getBuckets'); - - cy.findByText(bucketLabel) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Delete').should('be.visible').click(); - }); - - ui.dialog - .findByTitle(`Delete Bucket ${bucketLabel}`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.enabled') - .should('be.visible') - .click(); - }); - - cy.wait('@deleteBucket'); - cy.findByText('S3-compatible storage solution').should('be.visible'); - }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts new file mode 100644 index 00000000000..56e1e24d44f --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts @@ -0,0 +1,406 @@ +import { buildArray } from 'support/util/arrays'; +import { extendRegion } from 'support/util/regions'; +import { + accountFactory, + regionFactory, + objectStorageKeyFactory, + objectStorageBucketFactory, +} from 'src/factories'; +import { + randomString, + randomNumber, + randomLabel, + randomDomainName, +} from 'support/util/random'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockGetAccessKeys, + mockCreateAccessKey, + mockGetBucketsForRegion, + mockUpdateAccessKey, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; + +import type { ObjectStorageKeyBucketAccess } from '@linode/api-v4'; + +describe('Object Storage Multicluster access keys', () => { + const mockRegionsObj = buildArray(3, () => { + return extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + }); + + const mockRegions = [...mockRegionsObj]; + + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage', 'Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: false }, + }); + }); + + /* + * - Confirms user can create access keys with unlimited access when OBJ Multicluster is enabled. + * - Confirms multiple regions can be selected when creating an access key. + * - Confirms that UI updates to reflect created access key. + */ + it('can create unlimited access keys with OBJ Multicluster', () => { + const mockAccessKey = objectStorageKeyFactory.build({ + id: randomNumber(10000, 99999), + label: randomLabel(), + access_key: randomString(20), + secret_key: randomString(39), + regions: mockRegionsObj.map((mockObjRegion) => ({ + id: mockObjRegion.id, + s3_endpoint: randomDomainName(), + })), + }); + + mockGetAccessKeys([]); + mockCreateAccessKey(mockAccessKey).as('createAccessKey'); + mockGetRegions(mockRegions); + mockRegions.forEach((region) => { + mockGetBucketsForRegion(region.id, []); + }); + + cy.visitWithLogin('/object-storage/access-keys'); + + ui.button + .findByTitle('Create Access Key') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetAccessKeys([mockAccessKey]); + ui.drawer + .findByTitle('Create Access Key') + .should('be.visible') + .within(() => { + cy.contains('Label (required)') + .should('be.visible') + .click() + .type(mockAccessKey.label); + + cy.contains('Regions (required)').should('be.visible').click(); + + // Select each region with the OBJ capability. + mockRegionsObj.forEach((mockRegion) => { + cy.contains('Regions (required)').type(mockRegion.label); + ui.autocompletePopper + .findByTitle(`${mockRegion.label} (${mockRegion.id})`) + .should('be.visible') + .click(); + }); + + // Close the regions drop-down. + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type('{esc}'); + + // TODO Confirm expected regions are shown. + ui.buttonGroup + .findButtonByTitle('Create Access Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createAccessKey'); + ui.dialog + .findByTitle('Access Keys') + .should('be.visible') + .within(() => { + // TODO Add assertions for S3 hostnames + cy.get('input[id="access-key"]') + .should('be.visible') + .should('have.value', mockAccessKey.access_key); + cy.get('input[id="secret-key"]') + .should('be.visible') + .should('have.value', mockAccessKey.secret_key); + + ui.button + .findByTitle('I Have Saved My Secret Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(mockAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + // TODO Add assertions for regions/S3 hostnames + cy.findByText(mockAccessKey.access_key).should('be.visible'); + }); + }); + + /* + * - Confirms user can create access keys with limited access when OBJ Multicluster is enabled. + * - Confirms that UI updates to reflect created access key. + * - Confirms that "Permissions" drawer contains expected scope and permission data. + */ + it('can create limited access keys with OBJ Multicluster', () => { + const mockRegion = extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockBuckets = objectStorageBucketFactory.buildList(2, { + region: mockRegion.id, + cluster: undefined, + }); + + const mockAccessKey = objectStorageKeyFactory.build({ + id: randomNumber(10000, 99999), + label: randomLabel(), + access_key: randomString(20), + secret_key: randomString(39), + regions: [ + { + id: mockRegion.id, + s3_endpoint: randomDomainName(), + }, + ], + limited: true, + bucket_access: mockBuckets.map( + (bucket): ObjectStorageKeyBucketAccess => ({ + bucket_name: bucket.label, + cluster: '', + permissions: 'read_only', + region: mockRegion.id, + }) + ), + }); + + mockGetAccessKeys([]); + mockCreateAccessKey(mockAccessKey).as('createAccessKey'); + mockGetRegions([mockRegion]); + mockGetBucketsForRegion(mockRegion.id, mockBuckets); + + // Navigate to access keys page, click "Create Access Key" button. + cy.visitWithLogin('/object-storage/access-keys'); + ui.button + .findByTitle('Create Access Key') + .should('be.visible') + .should('be.enabled') + .click(); + + // Fill out form in "Create Access Key" drawer. + ui.drawer + .findByTitle('Create Access Key') + .should('be.visible') + .within(() => { + cy.contains('Label (required)') + .should('be.visible') + .click() + .type(mockAccessKey.label); + + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type(`${mockRegion.label}{enter}`); + + ui.autocompletePopper + .findByTitle(`${mockRegion.label} (${mockRegion.id})`) + .should('be.visible'); + + // Dismiss region drop-down. + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type('{esc}'); + + // Enable "Limited Access" toggle for access key and confirm Create button is disabled. + cy.findByText('Limited Access').should('be.visible').click(); + + ui.buttonGroup + .findButtonByTitle('Create Access Key') + .should('be.disabled'); + + // Select access rules for all buckets to enable Create button. + mockBuckets.forEach((mockBucket) => { + cy.findByText(mockBucket.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByLabelText( + `read-only for ${mockRegion.id}-${mockBucket.label}` + ) + .should('be.enabled') + .click(); + }); + }); + + mockGetAccessKeys([mockAccessKey]); + ui.buttonGroup + .findButtonByTitle('Create Access Key') + .should('be.enabled') + .click(); + }); + + // Dismiss secrets dialog. + cy.wait('@createAccessKey'); + ui.buttonGroup + .findButtonByTitle('I Have Saved My Secret Key') + .should('be.visible') + .should('be.enabled') + .click(); + + // Open "Permissions" drawer for new access key. + cy.findByText(mockAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Object Storage Key ${mockAccessKey.label}` + ) + .should('be.visible') + .click(); + }); + + ui.actionMenuItem.findByTitle('Permissions').click(); + ui.drawer + .findByTitle(`Permissions for ${mockAccessKey.label}`) + .should('be.visible') + .within(() => { + mockBuckets.forEach((mockBucket) => { + // TODO M3-7733 Update this selector when ARIA label is fixed. + cy.findByLabelText( + `This token has read-only access for ${mockRegion.id}-${mockBucket.label}` + ); + }); + }); + }); + + /* + * - Confirms user can edit access key labels and regions when OBJ Multicluster is enabled. + * - Confirms that user can deselect regions via the region selection list. + * - Confirms that access keys landing page automatically updates to reflect edited access key. + */ + it('can update access keys with OBJ Multicluster', () => { + const mockInitialRegion = extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockUpdatedRegion = extendRegion( + regionFactory.build({ + id: `us-${randomString(5)}`, + label: `mock-obj-region-${randomString(5)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockRegions = [mockInitialRegion, mockUpdatedRegion]; + + const mockAccessKey = objectStorageKeyFactory.build({ + id: randomNumber(10000, 99999), + label: randomLabel(), + access_key: randomString(20), + secret_key: randomString(39), + regions: [ + { + id: mockInitialRegion.id, + s3_endpoint: randomDomainName(), + }, + ], + }); + + const mockUpdatedAccessKeyEndpoint = randomDomainName(); + + const mockUpdatedAccessKey = { + ...mockAccessKey, + label: randomLabel(), + regions: [ + { + id: mockUpdatedRegion.id, + s3_endpoint: mockUpdatedAccessKeyEndpoint, + }, + ], + }; + + mockGetAccessKeys([mockAccessKey]); + mockGetRegions(mockRegions); + cy.visitWithLogin('/object-storage/access-keys'); + + cy.findByText(mockAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Object Storage Key ${mockAccessKey.label}` + ) + .should('be.visible') + .click(); + }); + + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Edit Access Key') + .should('be.visible') + .within(() => { + cy.contains('Label (required)') + .should('be.visible') + .click() + .type('{selectall}{backspace}') + .type(mockUpdatedAccessKey.label); + + cy.contains('Regions (required)') + .should('be.visible') + .click() + .type(`${mockUpdatedRegion.label}{enter}{esc}`); + + cy.contains(mockUpdatedRegion.label).should('be.visible').and('exist'); + + // Directly find the close button within the chip + cy.findByTestId(`${mockUpdatedRegion.id}`) + .findByTestId('CloseIcon') + .click(); + + mockUpdateAccessKey(mockUpdatedAccessKey).as('updateAccessKey'); + mockGetAccessKeys([mockUpdatedAccessKey]); + ui.button + .findByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@updateAccessKey'); + + // Confirm that access key landing page reflects updated key. + cy.findByText(mockAccessKey.label).should('not.exist'); + cy.findByText(mockUpdatedAccessKey.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.contains(mockUpdatedRegion.label).should('be.visible'); + cy.contains(mockUpdatedAccessKeyEndpoint).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts new file mode 100644 index 00000000000..cccbd8542cd --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts @@ -0,0 +1,138 @@ +import { extendRegion } from 'support/util/regions'; +import { + accountFactory, + regionFactory, + objectStorageBucketFactory, +} from 'src/factories'; +import { randomLabel, randomString } from 'support/util/random'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreateBucket, + mockCreateBucketError, + mockGetBuckets, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; + +describe('Object Storage Multicluster Bucket create', () => { + /* + * - Tests Object Storage bucket creation flow when OBJ Multicluster is enabled. + * - Confirms that expected regions are displayed in drop-down. + * - Confirms that region can be selected during create. + * - Confirms that API errors are handled gracefully by drawer. + * - Confirms that request payload contains desired Bucket region and not cluster. + * - Confirms that created Bucket is listed on the landing page. + */ + it('can create object storage bucket with OBJ Multicluster', () => { + const mockErrorMessage = 'An unknown error has occurred.'; + + const mockRegionWithObj = extendRegion( + regionFactory.build({ + label: randomLabel(), + id: `${randomString(2)}-${randomString(3)}`, + capabilities: ['Object Storage'], + }) + ); + + const mockRegionsWithoutObj = regionFactory + .buildList(2, { + capabilities: [], + }) + .map((region) => extendRegion(region)); + + const mockRegions = [mockRegionWithObj, ...mockRegionsWithoutObj]; + + const mockBucket = objectStorageBucketFactory.build({ + label: randomLabel(), + region: mockRegionWithObj.id, + cluster: undefined, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage', 'Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: false }, + }).as('getFeatureFlags'); + + mockGetRegions(mockRegions).as('getRegions'); + mockGetBuckets([]).as('getBuckets'); + mockCreateBucketError(mockErrorMessage).as('createBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait(['@getRegions', '@getBuckets']); + + ui.entityHeader.find().within(() => { + ui.button.findByTitle('Create Bucket').should('be.visible').click(); + }); + + ui.drawer + .findByTitle('Create Bucket') + .should('be.visible') + .within(() => { + // Enter label. + cy.contains('Label').click().type(mockBucket.label); + cy.log(`${mockRegionWithObj.label}`); + cy.contains('Region').click().type(mockRegionWithObj.label); + + ui.autocompletePopper + .find() + .should('be.visible') + .within(() => { + // Confirm that regions without 'Object Storage' capability are not listed. + mockRegionsWithoutObj.forEach((mockRegionWithoutObj) => { + cy.contains(mockRegionWithoutObj.id).should('not.exist'); + }); + + // Confirm that region with 'Object Storage' capability is listed, + // then select it. + cy.findByText( + `${mockRegionWithObj.label} (${mockRegionWithObj.id})` + ) + .should('be.visible') + .click(); + }); + + // Close region select. + cy.contains('Region').click(); + + // On first attempt, mock an error response and confirm message is shown. + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .click(); + + cy.wait('@createBucket'); + cy.findByText(mockErrorMessage).should('be.visible'); + + // Click submit again, mock a successful response. + mockCreateBucket(mockBucket).as('createBucket'); + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .click(); + }); + + // Confirm that Cloud includes the "region" property and omits the "cluster" + // property in its payload when creating a bucket. + cy.wait('@createBucket').then((xhr) => { + const body = xhr.request.body; + expect(body.cluster).to.be.undefined; + expect(body.region).to.eq(mockRegionWithObj.id); + }); + + cy.findByText(mockBucket.label) + .should('be.visible') + .closest('tr') + .within(() => { + // TODO Confirm that bucket region is shown in landing page. + cy.findByText(mockBucket.hostname).should('be.visible'); + // cy.findByText(mockRegionWithObj.label).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts new file mode 100644 index 00000000000..d810cab82ab --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts @@ -0,0 +1,65 @@ +import { randomLabel } from 'support/util/random'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetBuckets, + mockDeleteBucket, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; + +describe('Object Storage Multicluster Bucket delete', () => { + /* + * - Tests core object storage bucket deletion flow using mocked API responses. + * - Mocks existing buckets. + * - Deletes mocked bucket, confirms that landing page reflects deletion. + */ + it('can delete object storage bucket with OBJ Multicluster', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + label: bucketLabel, + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage', 'Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: true, + objectStorageGen2: { enabled: false }, + }); + + mockGetBuckets([bucketMock]).as('getBuckets'); + mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait('@getBuckets'); + + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + cy.wait('@deleteBucket'); + cy.findByText('S3-compatible storage solution').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts similarity index 75% rename from packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts rename to packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts index fc2e75c90e7..a5cc6e7158e 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/bucket-details.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts @@ -7,8 +7,10 @@ import { regionFactory, } from 'src/factories'; import { randomLabel } from 'support/util/random'; +import { mockGetBucket } from 'support/intercepts/object-storage'; +import { mockGetRegions } from 'support/intercepts/regions'; -describe('Object Storage Gen 1 Bucket Details Tabs', () => { +describe('Object Storage Multicluster Bucket Details Tabs', () => { beforeEach(() => { mockAppendFeatureFlags({ objMultiCluster: true, @@ -31,11 +33,17 @@ describe('Object Storage Gen 1 Bucket Details Tabs', () => { }); describe('Properties tab without required capabilities', () => { + /* + * - Confirms that Gen 2-specific "Properties" tab is absent when OBJ Multicluster is enabled. + */ it(`confirms the Properties tab does not exist for users without 'Object Storage Endpoint Types' capability`, () => { - const { region, label } = mockBucket; + const { label } = mockBucket; + + mockGetBucket(label, mockRegion.id); + mockGetRegions([mockRegion]); cy.visitWithLogin( - `/object-storage/buckets/${region}/${label}/properties` + `/object-storage/buckets/${mockRegion.id}/${label}/properties` ); cy.wait(['@getFeatureFlags', '@getAccount']); @@ -47,8 +55,6 @@ describe('Object Storage Gen 1 Bucket Details Tabs', () => { // Confirm that "Properties" tab is absent. cy.findByText('Properties').should('not.exist'); - - // TODO Confirm "Not Found" notice is present. }); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts new file mode 100644 index 00000000000..1138f4f99dc --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts @@ -0,0 +1,327 @@ +import 'cypress-file-upload'; +import { authenticate } from 'support/api/authentication'; +import { cleanUp } from 'support/util/cleanup'; +import { randomLabel } from 'support/util/random'; +import { ui } from 'support/ui'; +import { createObjectStorageBucketFactoryGen1 } from 'src/factories'; +import { interceptUploadBucketObjectS3 } from 'support/intercepts/object-storage'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { createBucket } from '@linode/api-v4'; + +// Message shown on-screen when user navigates to an empty bucket. +const emptyBucketMessage = 'This bucket is empty.'; + +// Message shown on-screen when user navigates to an empty folder. +const emptyFolderMessage = 'This folder is empty.'; + +/** + * Returns the non-empty bucket error message for a bucket with the given label. + * + * This message appears when attempting to delete a bucket that has one or + * more objects. + * + * @param bucketLabel - Label of bucket being deleted. + * + * @returns Non-empty bucket error message. + */ +const getNonEmptyBucketMessage = (bucketLabel: string) => { + return `Bucket ${bucketLabel} is not empty. Please delete all objects and try again.`; +}; + +/** + * Create a bucket with the given label and cluster. + * + * This function assumes that OBJ Multicluster is enabled. Use + * `setUpBucket` to set up OBJ buckets when Multicluster is disabled. + * + * @param label - Bucket label. + * @param regionId - ID of Bucket region. + * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. + * + * @returns Promise that resolves to created Bucket. + */ +const setUpBucketMulticluster = ( + label: string, + regionId: string, + cors_enabled: boolean = true +) => { + return createBucket( + createObjectStorageBucketFactoryGen1.build({ + label, + region: regionId, + cors_enabled, + + // API accepts either `cluster` or `region`, but not both. Our factory + // populates both fields, so we have to manually set `cluster` to `undefined` + // to avoid 400 responses from the API. + cluster: undefined, + }) + ); +}; + +/** + * Asserts that a URL assigned to an alias responds with a given status code. + * + * @param urlAlias - Cypress alias containing the URL to request. + * @param expectedStatus - HTTP status to expect for URL. + */ +const assertStatusForUrlAtAlias = ( + urlAlias: string, + expectedStatus: number +) => { + cy.get(urlAlias).then((url: unknown) => { + // An alias can resolve to anything. We're assuming the user passed a valid + // alias which resolves to a string. + cy.request({ + url: url as string, + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(expectedStatus); + }); + }); +}; + +/** + * Uploads the file at the given path and assigns it the given filename. + * + * This assumes that Cypress has already navigated to a page where a file + * upload prompt is present. + * + * @param filepath - Path to file to upload. + * @param filename - Filename to assign to uploaded file. + */ +const uploadFile = (filepath: string, filename: string) => { + cy.fixture(filepath, null).then((contents) => { + cy.get('[data-qa-drop-zone]').attachFile( + { + fileContent: contents, + fileName: filename, + }, + { + subjectType: 'drag-n-drop', + } + ); + }); +}; + +authenticate(); +describe('Object Storage Multicluster objects', () => { + before(() => { + cleanUp('obj-buckets'); + }); + + beforeEach(() => { + cy.tag('method:e2e'); + mockAppendFeatureFlags({ + objMultiCluster: true, + }); + }); + + /* + * - Confirms that users can upload new objects. + * - Confirms that users can replace objects with identical filenames. + * - Confirms that users can delete objects. + * - Confirms that users can create folders. + * - Confirms that users can delete empty folders. + * - Confirms that users cannot delete folders with objects. + * - Confirms that users cannot delete buckets with objects. + * - Confirms that private objects cannot be accessed over HTTP. + * - Confirms that public objects can be accessed over HTTP. + */ + it('can upload, access, and delete objects', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketRegionId = 'us-southeast'; + const bucketPage = `/object-storage/buckets/${bucketRegionId}/${bucketLabel}/objects`; + const bucketFolderName = randomLabel(); + + const bucketFiles = [ + { path: 'object-storage-files/1.txt', name: '1.txt' }, + { path: 'object-storage-files/2.jpg', name: '2.jpg' }, + ]; + + cy.defer( + () => setUpBucketMulticluster(bucketLabel, bucketRegionId), + 'creating Object Storage bucket' + ).then(() => { + interceptUploadBucketObjectS3( + bucketLabel, + bucketCluster, + bucketFiles[0].name + ).as('uploadObject'); + + // Navigate to new bucket page, upload and delete an object. + cy.visitWithLogin(bucketPage); + ui.entityHeader.find().within(() => { + cy.findByText(bucketLabel).should('be.visible'); + }); + + uploadFile(bucketFiles[0].path, bucketFiles[0].name); + + // @TODO Investigate why files do not appear automatically in Cypress. + cy.wait('@uploadObject'); + cy.reload(); + + cy.findByText(bucketFiles[0].name).should('be.visible'); + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFiles[0].name}`) + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .click(); + }); + + cy.findByText(emptyBucketMessage).should('be.visible'); + cy.findByText(bucketFiles[0].name).should('not.exist'); + + // Create a folder, navigate into it and upload object. + ui.button.findByTitle('Create Folder').should('be.visible').click(); + + ui.drawer + .findByTitle('Create Folder') + .should('be.visible') + .within(() => { + cy.findByLabelText('Folder Name') + .should('be.visible') + .click() + .type(bucketFolderName); + + ui.buttonGroup + .findButtonByTitle('Create') + .should('be.visible') + .click(); + }); + + cy.findByText(bucketFolderName).should('be.visible').click(); + + cy.findByText(emptyFolderMessage).should('be.visible'); + interceptUploadBucketObjectS3( + bucketLabel, + bucketCluster, + `${bucketFolderName}/${bucketFiles[1].name}` + ).as('uploadObject'); + uploadFile(bucketFiles[1].path, bucketFiles[1].name); + cy.wait('@uploadObject'); + + // Re-upload file to confirm replace prompt behavior. + uploadFile(bucketFiles[1].path, bucketFiles[1].name); + cy.findByText( + 'This file already exists. Are you sure you want to overwrite it?' + ); + ui.button.findByTitle('Replace').should('be.visible').click(); + cy.wait('@uploadObject'); + + // Confirm that you cannot delete a bucket with objects in it. + cy.visitWithLogin('/object-storage/buckets'); + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByText('Bucket Name').click().type(bucketLabel); + + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText(getNonEmptyBucketMessage(bucketLabel)).should( + 'be.visible' + ); + }); + + // Confirm that you cannot delete a folder with objects in it. + cy.visitWithLogin(bucketPage); + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFolderName}`) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + + cy.findByText('The folder must be empty to delete it.').should( + 'be.visible' + ); + + ui.button.findByTitle('Cancel').should('be.visible').click(); + }); + + // Confirm public/private access controls work as expected. + cy.findByText(bucketFolderName).should('be.visible').click(); + cy.findByText(bucketFiles[1].name).should('be.visible').click(); + + ui.drawer + .findByTitle(`${bucketFolderName}/${bucketFiles[1].name}`) + .should('be.visible') + .within(() => { + // Confirm that object is not public by default. + cy.get('[data-testid="external-site-link"]') + .should('be.visible') + .invoke('attr', 'href') + .as('bucketObjectUrl'); + + assertStatusForUrlAtAlias('@bucketObjectUrl', 403); + + // Make object public, confirm it can be accessed, then close drawer. + cy.findByLabelText('Access Control List (ACL)') + .should('be.visible') + .should('not.have.value', 'Loading access...') + .should('have.value', 'Private') + .click() + .type('Public Read'); + + ui.autocompletePopper + .findByTitle('Public Read') + .should('be.visible') + .click(); + + ui.button.findByTitle('Save').should('be.visible').click(); + + cy.findByText('Object access updated successfully.'); + assertStatusForUrlAtAlias('@bucketObjectUrl', 200); + + ui.drawerCloseButton.find().should('be.visible').click(); + }); + + // Delete object, then delete folder that contained the object. + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFiles[1].name}`) + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .click(); + }); + + cy.findByText(emptyFolderMessage).should('be.visible'); + + cy.visitWithLogin(bucketPage); + ui.button.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${bucketFolderName}`) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + // Confirm that bucket is empty. + cy.findByText(emptyBucketMessage).should('be.visible'); + }); + }); +}); From a2686c192ec4d70f92f6970641b4e12cf97672c0 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 10 Jan 2025 18:56:07 +0100 Subject: [PATCH 06/42] feat: [UIE-8134] - add new entities component (#11429) --- ...r-11429-upcoming-features-1734442308306.md | 5 + packages/api-v4/src/resources/types.ts | 6 +- ...r-11429-upcoming-features-1734442339368.md | 5 + .../manager/src/factories/accountResources.ts | 127 ++++++++++++++---- .../IAM/Shared/Entities/Entities.test.tsx | 118 ++++++++++++++++ .../features/IAM/Shared/Entities/Entities.tsx | 95 +++++++++++++ .../src/features/IAM/Shared/utilities.ts | 14 ++ .../IAM/Users/UserRoles/UserRoles.tsx | 10 +- packages/manager/src/mocks/serverHandlers.ts | 8 ++ 9 files changed, 357 insertions(+), 31 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11429-upcoming-features-1734442308306.md create mode 100644 packages/manager/.changeset/pr-11429-upcoming-features-1734442339368.md create mode 100644 packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx create mode 100644 packages/manager/src/features/IAM/Shared/Entities/Entities.tsx diff --git a/packages/api-v4/.changeset/pr-11429-upcoming-features-1734442308306.md b/packages/api-v4/.changeset/pr-11429-upcoming-features-1734442308306.md new file mode 100644 index 00000000000..239fe403802 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11429-upcoming-features-1734442308306.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +update types for iam and resources api ([#11429](https://github.com/linode/manager/pull/11429)) diff --git a/packages/api-v4/src/resources/types.ts b/packages/api-v4/src/resources/types.ts index bf6d89ad037..680aa4c03a5 100644 --- a/packages/api-v4/src/resources/types.ts +++ b/packages/api-v4/src/resources/types.ts @@ -1,4 +1,4 @@ -type ResourceType = +export type ResourceType = | 'linode' | 'firewall' | 'nodebalancer' @@ -10,10 +10,10 @@ type ResourceType = | 'database' | 'vpc'; -export type IamAccountResource = { +export interface IamAccountResource { resource_type: ResourceType; resources: Resource[]; -}[]; +} export interface Resource { name: string; diff --git a/packages/manager/.changeset/pr-11429-upcoming-features-1734442339368.md b/packages/manager/.changeset/pr-11429-upcoming-features-1734442339368.md new file mode 100644 index 00000000000..4ae5c5df004 --- /dev/null +++ b/packages/manager/.changeset/pr-11429-upcoming-features-1734442339368.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +add new entities component for iam ([#11429](https://github.com/linode/manager/pull/11429)) diff --git a/packages/manager/src/factories/accountResources.ts b/packages/manager/src/factories/accountResources.ts index df33e999ac8..e9bb03940e5 100644 --- a/packages/manager/src/factories/accountResources.ts +++ b/packages/manager/src/factories/accountResources.ts @@ -1,29 +1,102 @@ -import { IamAccountResource } from '@linode/api-v4'; import Factory from 'src/factories/factoryProxy'; -export const accountResourcesFactory = Factory.Sync.makeFactory( - [ - { - resource_type: 'linode', - resources: [ - { - name: 'debian-us-123', - id: 12345678, - }, - { - name: 'linode-uk-123', - id: 23456789, - }, - ], - }, - { - resource_type: 'firewall', - resources: [ - { - name: 'firewall-us-123', - id: 45678901, - }, - ], - }, - ] -); +import type { IamAccountResource } from '@linode/api-v4'; + +export const accountResourcesFactory = Factory.Sync.makeFactory< + IamAccountResource[] +>([ + { + resource_type: 'linode', + resources: [ + { + id: 12345678, + name: 'debian-us-123', + }, + { + id: 23456789, + name: 'linode-uk-123', + }, + ], + }, + { + resource_type: 'firewall', + resources: [ + { + id: 45678901, + name: 'firewall-us-123', + }, + ], + }, + { + resource_type: 'image', + resources: [ + { + id: 65789745, + name: 'image-us-123', + }, + ], + }, + { + resource_type: 'vpc', + resources: [ + { + id: 7654321, + name: 'vpc-us-123', + }, + ], + }, + { + resource_type: 'volume', + resources: [ + { + id: 890357, + name: 'volume-us-123', + }, + ], + }, + { + resource_type: 'nodebalancer', + resources: [ + { + id: 4532187, + name: 'nodebalancer-us-123', + }, + ], + }, + { + resource_type: 'longview', + resources: [ + { + id: 432178973, + name: 'longview-us-123', + }, + ], + }, + { + resource_type: 'domain', + resources: [ + { + id: 5437894, + name: 'domain-us-123', + }, + ], + }, + { + resource_type: 'stackscript', + resources: [ + { + id: 654321789, + name: 'stackscript-us-123', + }, + ], + }, + { + resource_type: 'database', + resources: [ + { + id: 643218965, + name: 'database-us-123', + }, + ], + }, +]); diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx new file mode 100644 index 00000000000..15304503266 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Entities } from './Entities'; + +import type { IamAccountResource } from '@linode/api-v4/lib/resources/types'; + +const queryMocks = vi.hoisted(() => ({ + useAccountResources: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/resources/resources', async () => { + const actual = await vi.importActual('src/queries/resources/resources'); + return { + ...actual, + useAccountResources: queryMocks.useAccountResources, + }; +}); + +const mockResources: IamAccountResource[] = [ + { + resource_type: 'linode', + resources: [ + { + id: 23456789, + name: 'linode-uk-123', + }, + { + id: 456728, + name: 'db-us-southeast1', + }, + ], + }, + { + resource_type: 'image', + resources: [ + { id: 3, name: 'image-1' }, + { id: 4, name: 'image-2' }, + ], + }, +]; + +describe('Resources', () => { + it('renders correct data when it is an account access and type is an account', () => { + const { getByText, queryAllByRole } = renderWithTheme( + + ); + + const autocomplete = queryAllByRole('combobox'); + + expect(getByText('Entities')).toBeInTheDocument(); + expect(getByText('All entities')).toBeInTheDocument(); + + // check that the autocomplete doesn't exist + expect(autocomplete.length).toBe(0); + expect(autocomplete[0]).toBeUndefined(); + }); + + it('renders correct data when it is an account access and type is not an account', () => { + const { getByText, queryAllByRole } = renderWithTheme( + + ); + + const autocomplete = queryAllByRole('combobox'); + + expect(getByText('Entities')).toBeInTheDocument(); + expect(getByText('All firewalls')).toBeInTheDocument(); + + // check that the autocomplete doesn't exist + expect(autocomplete.length).toBe(0); + expect(autocomplete[0]).toBeUndefined(); + }); + + it('renders correct data when it is a resources access', () => { + queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); + + const { getAllByRole, getByText } = renderWithTheme( + + ); + + expect(getByText('Entities')).toBeInTheDocument(); + + // Verify comboboxes exist + const autocomplete = getAllByRole('combobox'); + expect(autocomplete).toHaveLength(1); + expect(autocomplete[0]).toBeInTheDocument(); + expect(autocomplete[0]).toHaveAttribute('placeholder', 'Select Images'); + }); + + it('renders correct options in Autocomplete dropdown when it is a resources access', () => { + queryMocks.useAccountResources.mockReturnValue({ data: mockResources }); + + const { getAllByRole, getByText } = renderWithTheme( + + ); + + expect(getByText('Entities')).toBeInTheDocument(); + + const autocomplete = getAllByRole('combobox')[0]; + fireEvent.focus(autocomplete); + fireEvent.mouseDown(autocomplete); + expect(getByText('image-1')).toBeInTheDocument(); + expect(getByText('image-2')).toBeInTheDocument(); + }); + + it('updates selected options when Autocomplete value changes when it is a resources access', () => { + const { getAllByRole, getByText } = renderWithTheme( + + ); + + const autocomplete = getAllByRole('combobox')[0]; + fireEvent.change(autocomplete, { target: { value: 'linode-uk-123' } }); + fireEvent.keyDown(autocomplete, { key: 'Enter' }); + expect(getByText('linode-uk-123')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx new file mode 100644 index 00000000000..dcce25ff8ec --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx @@ -0,0 +1,95 @@ +import { Autocomplete, Typography } from '@linode/ui'; +import React from 'react'; + +import { FormLabel } from 'src/components/FormLabel'; +import { useAccountResources } from 'src/queries/resources/resources'; + +import { placeholderMap } from '../utilities'; + +import type { + IamAccessType, + IamAccountResource, + Resource, + ResourceType, + ResourceTypePermissions, +} from '@linode/api-v4'; + +interface Props { + access: IamAccessType; + type: ResourceType | ResourceTypePermissions; +} + +interface EntitiesOption { + label: string; + value: number; +} + +export const Entities = ({ access, type }: Props) => { + const { data: resources } = useAccountResources(); + + const [selectedEntities, setSelectedEntities] = React.useState< + EntitiesOption[] + >([]); + + const memoizedEntities = React.useMemo(() => { + if (access !== 'resource_access' || !resources) { + return []; + } + const typeResources = getEntitiesByType(type, resources); + return typeResources ? transformedEntities(typeResources.resources) : []; + }, [resources, access, type]); + + if (access === 'account_access') { + return ( + <> + + + Entities + + + + {type === 'account' ? 'All entities' : `All ${type}s`} + + + ); + } + + return ( + ( +
  • + {option.label} +
  • + )} + ListboxProps={{ sx: { overflowX: 'hidden' } }} + label="Entities" + multiple + onChange={(_, value) => setSelectedEntities(value)} + options={memoizedEntities} + placeholder={selectedEntities.length ? ' ' : getPlaceholder(type)} + sx={{ marginTop: 1 }} + /> + ); +}; + +const getPlaceholder = (type: ResourceType | ResourceTypePermissions): string => + placeholderMap[type] || 'Select'; + +const transformedEntities = (entities: Resource[]): EntitiesOption[] => { + return entities.map((entity) => ({ + label: entity.name, + value: entity.id, + })); +}; + +const getEntitiesByType = ( + roleResourceType: ResourceType | ResourceTypePermissions, + resources: IamAccountResource +): IamAccountResource | undefined => { + const entitiesArray: IamAccountResource[] = Object.values(resources); + + // Find the first matching entity by resource_type + return entitiesArray.find( + (item: IamAccountResource) => item.resource_type === roleResourceType + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index 1b4f968511a..fef7ff9da58 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -16,3 +16,17 @@ export const useIsIAMEnabled = () => { isIAMEnabled, }; }; + +export const placeholderMap: Record = { + account: 'Select Account', + database: 'Select Databases', + domain: 'Select Domains', + firewall: 'Select Firewalls', + image: 'Select Images', + linode: 'Select Linodes', + longview: 'Select Longviews', + nodebalancer: 'Select Nodebalancers', + stackscript: 'Select Stackscripts', + volume: 'Select Volumes', + vpc: 'Select VPCs', +}; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index 229989cf392..15d16731851 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { NO_ASSIGNED_ROLES_TEXT } from '../../Shared/constants'; +import { Entities } from '../../Shared/Entities/Entities'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; import type { IamUserPermissions } from '@linode/api-v4'; @@ -39,7 +40,14 @@ export const UserRoles = ({ assignedRoles }: Props) => { {hasAssignedRoles ? ( -

    UIE-8138 - assigned roles table

    +
    +

    UIE-8138 - assigned roles table

    + {/* just for showing the Entities componnet, it will be gone wuth the AssignedPermissions component*/} + + + + +
    ) : ( )} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index da3347b9894..3f48d7490c8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -129,6 +129,7 @@ import type { VolumeStatus, } from '@linode/api-v4'; import { userPermissionsFactory } from 'src/factories/userPermissions'; +import { accountResourcesFactory } from 'src/factories/accountResources'; export const makeResourcePage = ( e: T[], @@ -398,6 +399,12 @@ const iam = [ }), ]; +const resources = [ + http.get('*/v4*/resources', () => { + return HttpResponse.json(accountResourcesFactory.build()); + }), +]; + const nanodeType = linodeTypeFactory.build({ id: 'g6-nanode-1' }); const standardTypes = linodeTypeFactory.buildList(7); const dedicatedTypes = dedicatedTypeFactory.buildList(7); @@ -2763,4 +2770,5 @@ export const handlers = [ ...databases, ...vpc, ...iam, + ...resources, ]; From 584aab4f4f28b67b4d72b1b9cde6bdf03d364597 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 13 Jan 2025 11:57:26 +0530 Subject: [PATCH 07/42] refactor: [M3-9016] - Convert `DomainRecords.tsx` to functional component (#11447) * Initial commit * More refactoring... * Consolidate Pagination Footer * Few fixes... * Fix NS record domain render * Added changeset: Refactor DomainRecords and convert DomainRecords to functional component * Update changeset * Fix DomainRecordTable paginatedData type * Refactor generateTypes * Memoize generateTypes * Utility rename for clarity * Update tablerow key * Change to more descriptive var names * Fix typo * Use scrollErrorIntoViewV2 * Fix linting * Refactor to remove some ramda dependencies and avoid `any` types --- .../pr-11447-tech-stories-1734960446943.md | 5 + .../ConfirmationDialog/ConfirmationDialog.tsx | 9 +- .../Domains/DomainDetail/DomainDetail.tsx | 2 +- .../DomainRecords}/DomainRecordActionMenu.tsx | 6 +- .../DomainRecordDrawer.test.tsx | 0 .../DomainRecords}/DomainRecordDrawer.tsx | 2 +- .../DomainRecords/DomainRecordTable.tsx | 88 ++ .../DomainRecords}/DomainRecords.styles.ts | 15 +- .../DomainRecords/DomainRecords.tsx | 337 +++++++ .../DomainRecords/DomainRecordsUtils.ts | 102 ++ .../DomainRecords/generateTypes.tsx | 455 +++++++++ .../src/features/Domains/DomainRecords.tsx | 889 ------------------ 12 files changed, 1007 insertions(+), 903 deletions(-) create mode 100644 packages/manager/.changeset/pr-11447-tech-stories-1734960446943.md rename packages/manager/src/features/Domains/{ => DomainDetail/DomainRecords}/DomainRecordActionMenu.tsx (86%) rename packages/manager/src/features/Domains/{ => DomainDetail/DomainRecords}/DomainRecordDrawer.test.tsx (100%) rename packages/manager/src/features/Domains/{ => DomainDetail/DomainRecords}/DomainRecordDrawer.tsx (99%) create mode 100644 packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx rename packages/manager/src/features/Domains/{ => DomainDetail/DomainRecords}/DomainRecords.styles.ts (98%) create mode 100644 packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx create mode 100644 packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts create mode 100644 packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx delete mode 100644 packages/manager/src/features/Domains/DomainRecords.tsx diff --git a/packages/manager/.changeset/pr-11447-tech-stories-1734960446943.md b/packages/manager/.changeset/pr-11447-tech-stories-1734960446943.md new file mode 100644 index 00000000000..2480725bedc --- /dev/null +++ b/packages/manager/.changeset/pr-11447-tech-stories-1734960446943.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Tech Stories +--- + +Refactor and convert DomainRecords to functional component ([#11447](https://github.com/linode/manager/pull/11447)) diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 43937633388..880b54598b2 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -21,11 +21,14 @@ export interface ConfirmationDialogProps extends DialogProps { * - Avoid โ€œAre you sure?โ€ language. Assume the user knows what they want to do while helping them avoid unintended consequences. * */ -export const ConfirmationDialog = (props: ConfirmationDialogProps) => { +export const ConfirmationDialog = React.forwardRef< + HTMLDivElement, + ConfirmationDialogProps +>((props, ref) => { const { actions, children, ...dialogProps } = props; return ( - + {children} { ); -}; +}); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index 6d59718b8ae..c04480b163a 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -15,8 +15,8 @@ import { } from 'src/queries/domains'; import { DeleteDomain } from '../DeleteDomain'; -import DomainRecords from '../DomainRecords'; import { DownloadDNSZoneFileButton } from '../DownloadDNSZoneFileButton'; +import { DomainRecords } from './DomainRecords/DomainRecords'; import type { DomainState } from 'src/routes/domains'; diff --git a/packages/manager/src/features/Domains/DomainRecordActionMenu.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx similarity index 86% rename from packages/manager/src/features/Domains/DomainRecordActionMenu.tsx rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx index f49d8ccf3e0..4f5cadbd70a 100644 --- a/packages/manager/src/features/Domains/DomainRecordActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx @@ -1,8 +1,10 @@ -import { Domain } from '@linode/api-v4/lib/domains'; import { has } from 'ramda'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { Domain } from '@linode/api-v4/lib/domains'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface EditPayload { id?: number; diff --git a/packages/manager/src/features/Domains/DomainRecordDrawer.test.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Domains/DomainRecordDrawer.test.tsx rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.test.tsx diff --git a/packages/manager/src/features/Domains/DomainRecordDrawer.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx similarity index 99% rename from packages/manager/src/features/Domains/DomainRecordDrawer.tsx rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx index 8122a08cc26..8ed31e146ae 100644 --- a/packages/manager/src/features/Domains/DomainRecordDrawer.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx @@ -30,7 +30,7 @@ import { transferHelperText as helperText, isValidCNAME, isValidDomainRecord, -} from './domainUtils'; +} from '../../domainUtils'; import type { Domain, diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx new file mode 100644 index 00000000000..ebb575ff192 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordTable.tsx @@ -0,0 +1,88 @@ +import React from 'react'; + +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; + +import { StyledTableCell } from './DomainRecords.styles'; + +import type { IType } from './generateTypes'; +import type { Domain, DomainRecord } from '@linode/api-v4/lib/domains'; + +interface DomainRecordTableProps { + count: number; + handlePageChange: (page: number) => void; + handlePageSizeChange: (pageSize: number) => void; + page: number; + pageSize: number; + paginatedData: Domain[] | DomainRecord[]; + type: IType; +} + +export const DomainRecordTable = (props: DomainRecordTableProps) => { + const { + count, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + paginatedData, + type, + } = props; + + return ( + <> + + + + {type.columns.length > 0 && + type.columns.map((col, columnIndex) => { + return {col.title}; + })} + + + + {type.data.length === 0 ? ( + + ) : ( + paginatedData.map((data, idx) => { + return ( + + {type.columns.length > 0 && + type.columns.map(({ render, title }, columnIndex) => { + return ( + + {render(data)} + + ); + })} + + ); + }) + )} + +
    + + + ); +}; diff --git a/packages/manager/src/features/Domains/DomainRecords.styles.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts similarity index 98% rename from packages/manager/src/features/Domains/DomainRecords.styles.ts rename to packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts index 93f252e1f65..3ee8534e082 100644 --- a/packages/manager/src/features/Domains/DomainRecords.styles.ts +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts @@ -1,12 +1,10 @@ import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; + import { TableCell } from 'src/components/TableCell'; export const StyledGrid = styled(Grid, { label: 'StyledGrid' })( ({ theme }) => ({ - margin: 0, - marginTop: theme.spacing(2), - width: '100%', '& .MuiGrid-item': { paddingLeft: 0, paddingRight: 0, @@ -16,30 +14,33 @@ export const StyledGrid = styled(Grid, { label: 'StyledGrid' })( marginRight: theme.spacing(), }, }, + margin: 0, + marginTop: theme.spacing(2), [theme.breakpoints.down('md')]: { marginLeft: theme.spacing(), marginRight: theme.spacing(), }, + width: '100%', }) ); -export const StyledTableCell = styled(TableCell, { label: 'StyledTabelCell' })( +export const StyledTableCell = styled(TableCell, { label: 'StyledTableCell' })( ({ theme }) => ({ - whiteSpace: 'nowrap' as const, - width: 'auto', '& .data': { maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', - whiteSpace: 'nowrap' as const, [theme.breakpoints.up('md')]: { maxWidth: 750, }, + whiteSpace: 'nowrap' as const, }, '&:last-of-type': { display: 'flex', justifyContent: 'flex-end', }, + whiteSpace: 'nowrap' as const, + width: 'auto', }) ); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx new file mode 100644 index 00000000000..b33d4f986a7 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -0,0 +1,337 @@ +import { deleteDomainRecord as _deleteDomainRecord } from '@linode/api-v4/lib/domains'; +import { Typography } from '@linode/ui'; +import Grid from '@mui/material/Unstable_Grid2'; +import { lensPath, over } from 'ramda'; +import * as React from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import OrderBy from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { + getAPIErrorOrDefault, + getErrorStringOrDefault, +} from 'src/utilities/errorUtils'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import { storage } from 'src/utilities/storage'; + +import { DomainRecordDrawer } from './DomainRecordDrawer'; +import { StyledDiv, StyledGrid } from './DomainRecords.styles'; +import { DomainRecordTable } from './DomainRecordTable'; +import { generateTypes } from './generateTypes'; + +import type { GenerateTypesHandlers, IType } from './generateTypes'; +import type { + Domain, + DomainRecord, + DomainType, + RecordType, + UpdateDomainPayload, +} from '@linode/api-v4/lib/domains'; +import type { APIError } from '@linode/api-v4/lib/types'; + +interface UpdateDomainDataProps extends UpdateDomainPayload { + id: number; +} + +export interface Props { + domain: Domain; + domainRecords: DomainRecord[]; + updateDomain: (data: UpdateDomainDataProps) => Promise; + updateRecords: () => void; +} + +interface ConfirmationState { + errors?: APIError[]; + open: boolean; + recordId?: number; + submitting: boolean; +} + +interface DrawerState { + fields?: Partial | Partial; + mode: 'create' | 'edit'; + open: boolean; + type: DomainType | RecordType; +} + +interface State { + confirmDialog: ConfirmationState; + drawer: DrawerState; + types: IType[]; +} + +export const DomainRecords = (props: Props) => { + const { domain, domainRecords, updateDomain, updateRecords } = props; + + const defaultDrawerState: DrawerState = { + mode: 'create', + open: false, + type: 'NS', + }; + + const [state, setState] = React.useState({ + confirmDialog: { + open: false, + submitting: false, + }, + drawer: defaultDrawerState, + types: [], + }); + + const confirmDialogRef = React.useRef(null); + + const confirmDeletion = (recordId: number) => + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + open: true, + recordId, + })); + + const deleteDomainRecord = () => { + const { + domain: { id: domainId }, + } = props; + const { + confirmDialog: { recordId }, + } = state; + + if (!domainId || !recordId) { + return; + } + + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + errors: undefined, + submitting: true, + })); + + _deleteDomainRecord(domainId, recordId) + .then(() => { + updateRecords(); + + updateConfirmDialog((_) => ({ + errors: undefined, + open: false, + recordId: undefined, + submitting: false, + })); + }) + .catch((errorResponse) => { + const errors = getAPIErrorOrDefault(errorResponse); + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + errors, + submitting: false, + })); + }); + + updateConfirmDialog((confirmDialog) => ({ + ...confirmDialog, + submitting: true, + })); + }; + + const handleCloseDialog = () => { + updateConfirmDialog(() => ({ + open: false, + recordId: undefined, + submitting: false, + })); + }; + + const handleOpenSOADrawer = (domain: Domain) => { + return domain.type === 'master' + ? openForEditPrimaryDomain(domain) + : openForEditSecondaryDomain(domain); + }; + + const openForCreation = (type: RecordType) => + updateDrawer(() => ({ + mode: 'create', + open: true, + submitting: false, + type, + })); + + const openForEditing = ( + type: DomainType | RecordType, + fields: Partial | Partial + ) => + updateDrawer(() => ({ + fields, + mode: 'edit', + open: true, + submitting: false, + type, + })); + + const openForEditPrimaryDomain = (fields: Partial) => + openForEditing('master', fields); + + const openForEditSecondaryDomain = (fields: Partial) => + openForEditing('slave', fields); + + const renderDialogActions = () => { + return ( + + ); + }; + + const resetDrawer = () => updateDrawer(() => defaultDrawerState); + + const updateConfirmDialog = ( + fn: (confirmDialog: ConfirmationState) => ConfirmationState + ) => { + setState((prevState) => { + const newState = over(lensPath(['confirmDialog']), fn, prevState); + scrollErrorIntoViewV2(confirmDialogRef); + + return newState; + }); + }; + + const updateDrawer = (fn: (drawer: DrawerState) => DrawerState) => { + setState((prevState) => { + return over(lensPath(['drawer']), fn, prevState); + }); + }; + + const handlers: GenerateTypesHandlers = { + confirmDeletion, + handleOpenSOADrawer, + openForCreateARecord: () => openForCreation('AAAA'), + openForCreateCAARecord: () => openForCreation('CAA'), + openForCreateCNAMERecord: () => openForCreation('CNAME'), + openForCreateMXRecord: () => openForCreation('MX'), + openForCreateNSRecord: () => openForCreation('NS'), + openForCreateSRVRecord: () => openForCreation('SRV'), + openForCreateTXTRecord: () => openForCreation('TXT'), + openForEditARecord: (fields) => openForEditing('AAAA', fields), + openForEditCAARecord: (fields) => openForEditing('CAA', fields), + openForEditCNAMERecord: (fields) => openForEditing('CNAME', fields), + openForEditMXRecord: (fields) => openForEditing('MX', fields), + openForEditNSRecord: (fields) => openForEditing('NS', fields), + openForEditSRVRecord: (fields) => openForEditing('SRV', fields), + openForEditTXTRecord: (fields) => openForEditing('TXT', fields), + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const types = React.useMemo(() => generateTypes(props, handlers), [ + domain, + domainRecords, + ]); + + React.useEffect(() => { + setState((prevState) => ({ + ...prevState, + types, + })); + }, [types]); + + return ( + <> + + {state.types.map((type, eachTypeIdx) => { + const ref: React.RefObject = React.createRef(); + + return ( +
    + + + + {type.title} + + + {type.link && ( + + {' '} + {type.link()}{' '} + + )} + + + {({ data: orderedData }) => { + return ( + + {({ + count, + data: paginatedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + + )} + + ); + }} + +
    + ); + })} + + Are you sure you want to delete this record? + + + + ); +}; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts new file mode 100644 index 00000000000..e60047db478 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts @@ -0,0 +1,102 @@ +import { compose, pathOr } from 'ramda'; + +import type { Props } from './DomainRecords'; +import type { DomainRecord, RecordType } from '@linode/api-v4/lib/domains'; + +export const msToReadableTime = (v: number): null | string => + pathOr(null, [v], { + 0: 'Default', + 30: '30 seconds', + 120: '2 minutes', + 300: '5 minutes', + 3600: '1 hour', + 7200: '2 hours', + 14400: '4 hours', + 28800: '8 hours', + 57600: '16 hours', + 86400: '1 day', + 172800: '2 days', + 345600: '4 days', + 604800: '1 week', + 1209600: '2 weeks', + 2419200: '4 weeks', + }); + +export const getTTL = compose(msToReadableTime, pathOr(0, ['ttl_sec'])); + +export const typeEq = (type: RecordType) => (record: DomainRecord): boolean => + record.type === type; + +const prependLinodeNS: Partial[] = [ + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns1.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns2.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns3.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns4.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, + { + id: -1, + name: '', + port: 0, + priority: 0, + protocol: null, + service: null, + tag: null, + target: 'ns5.linode.com', + ttl_sec: 0, + type: 'NS', + weight: 0, + }, +]; + +export const getNSRecords = (props: Props): Partial[] => { + const domainRecords = props.domainRecords || []; + const filteredNSRecords = domainRecords.filter(typeEq('NS')); + return [...prependLinodeNS, ...filteredNSRecords]; +}; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx new file mode 100644 index 00000000000..8fc76d749f7 --- /dev/null +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx @@ -0,0 +1,455 @@ +import { Button } from '@linode/ui'; +import { compose, isEmpty, pathOr } from 'ramda'; +import React from 'react'; + +import { truncateEnd } from 'src/utilities/truncate'; + +import { DomainRecordActionMenu } from './DomainRecordActionMenu'; +import { + getNSRecords, + getTTL, + msToReadableTime, + typeEq, +} from './DomainRecordsUtils'; + +import type { Props as DomainRecordsProps } from './DomainRecords'; +import type { Domain, DomainRecord } from '@linode/api-v4/lib/domains'; + +export interface IType { + columns: { + render: (record: Domain | DomainRecord) => JSX.Element | null | string; + title: string; + }[]; + data: any[]; + link?: () => JSX.Element | null; + order: 'asc' | 'desc'; + orderBy: 'domain' | 'name' | 'target'; + title: string; +} + +export interface GenerateTypesHandlers { + confirmDeletion: (recordId: number) => void; + handleOpenSOADrawer: (domain: Domain) => void; + openForCreateARecord: () => void; + openForCreateCAARecord: () => void; + openForCreateCNAMERecord: () => void; + openForCreateMXRecord: () => void; + openForCreateNSRecord: () => void; + openForCreateSRVRecord: () => void; + openForCreateTXTRecord: () => void; + openForEditARecord: ( + fields: Pick + ) => void; + openForEditCAARecord: ( + fields: Pick + ) => void; + openForEditCNAMERecord: ( + fields: Pick + ) => void; + openForEditMXRecord: ( + fields: Pick< + DomainRecord, + 'id' | 'name' | 'priority' | 'target' | 'ttl_sec' + > + ) => void; + openForEditNSRecord: ( + fields: Pick + ) => void; + openForEditSRVRecord: ( + fields: Pick< + DomainRecord, + 'id' | 'name' | 'port' | 'priority' | 'protocol' | 'target' | 'weight' + > + ) => void; + openForEditTXTRecord: ( + fields: Pick + ) => void; +} + +const createLink = (title: string, handler: () => void) => ( + +); + +export const generateTypes = ( + props: DomainRecordsProps, + handlers: GenerateTypesHandlers +): IType[] => [ + /** SOA Record */ + { + columns: [ + { + render: (domain: Domain) => domain.domain, + title: 'Primary Domain', + }, + { + render: (domain: Domain) => domain.soa_email, + title: 'Email', + }, + { + render: getTTL, + title: 'Default TTL', + }, + { + render: compose(msToReadableTime, pathOr(0, ['refresh_sec'])), + title: 'Refresh Rate', + }, + { + render: compose(msToReadableTime, pathOr(0, ['retry_sec'])), + title: 'Retry Rate', + }, + { + render: compose(msToReadableTime, pathOr(0, ['expire_sec'])), + title: 'Expire Time', + }, + { + render: (domain: Domain) => { + return domain.type === 'master' ? ( + + ) : null; + }, + title: '', + }, + ], + data: [props.domain], + order: 'asc', + orderBy: 'domain', + title: 'SOA Record', + }, + + /** NS Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.target, + title: 'Name Server', + }, + { + render: (record: DomainRecord) => { + const subdomain = record.name; + return isEmpty(subdomain) + ? props.domain.domain + : `${subdomain}.${props.domain.domain}`; + }, + title: 'Subdomain', + }, + { + render: getTTL, + title: 'TTL', + }, + { + /** + * If the NS is one of Linode's, don't display the Action menu since the user + * cannot make changes to Linode's nameservers. + */ + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + + if (id === -1) { + return null; + } + + return ( + + ); + }, + title: '', + }, + ], + data: getNSRecords(props), + link: () => createLink('Add an NS Record', handlers.openForCreateNSRecord), + order: 'asc', + orderBy: 'target', + title: 'NS Record', + }, + + /** MX Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.target, + title: 'Mail Server', + }, + { + render: (record: DomainRecord) => String(record.priority), + title: 'Preference', + }, + { + render: (record: DomainRecord) => record.name, + title: 'Subdomain', + }, + { + render: getTTL, + title: 'TTL', + }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, priority, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('MX')), + link: () => createLink('Add a MX Record', handlers.openForCreateMXRecord), + order: 'asc', + orderBy: 'target', + title: 'MX Record', + }, + + /** A/AAAA Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.name || props.domain.domain, + title: 'Hostname', + }, + { render: (record: DomainRecord) => record.target, title: 'IP Address' }, + { render: getTTL, title: 'TTL' }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter( + (record) => typeEq('AAAA')(record) || typeEq('A')(record) + ), + link: () => + createLink('Add an A/AAAA Record', handlers.openForCreateARecord), + order: 'asc', + orderBy: 'name', + title: 'A/AAAA Record', + }, + + /** CNAME Record */ + { + columns: [ + { render: (record: DomainRecord) => record.name, title: 'Hostname' }, + { render: (record: DomainRecord) => record.target, title: 'Aliases to' }, + { render: getTTL, title: 'TTL' }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('CNAME')), + link: () => + createLink('Add a CNAME Record', handlers.openForCreateCNAMERecord), + order: 'asc', + orderBy: 'name', + title: 'CNAME Record', + }, + + /** TXT Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.name || props.domain.domain, + title: 'Hostname', + }, + { + render: (record: DomainRecord) => truncateEnd(record.target, 100), + title: 'Value', + }, + { render: getTTL, title: 'TTL' }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('TXT')), + link: () => createLink('Add a TXT Record', handlers.openForCreateTXTRecord), + order: 'asc', + orderBy: 'name', + title: 'TXT Record', + }, + /** SRV Record */ + { + columns: [ + { + render: (record: DomainRecord) => record.name, + title: 'Service/Protocol', + }, + { + render: () => props.domain.domain, + title: 'Name', + }, + { + render: (record: DomainRecord) => String(record.priority), + title: 'Priority', + }, + { + render: (record: DomainRecord) => String(record.weight), + title: 'Weight', + }, + { render: (record: DomainRecord) => String(record.port), title: 'Port' }, + { render: (record: DomainRecord) => record.target, title: 'Target' }, + { render: getTTL, title: 'TTL' }, + { + render: ({ + id, + port, + priority, + protocol, + service, + target, + weight, + }: DomainRecord) => ( + + ), + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('SRV')), + link: () => + createLink('Add an SRV Record', handlers.openForCreateSRVRecord), + order: 'asc', + orderBy: 'name', + title: 'SRV Record', + }, + + /** CAA Record */ + { + columns: [ + { render: (record: DomainRecord) => record.name, title: 'Name' }, + { render: (record: DomainRecord) => record.tag, title: 'Tag' }, + { + render: (record: DomainRecord) => record.target, + title: 'Value', + }, + { render: getTTL, title: 'TTL' }, + { + render: (domainRecordParams: DomainRecord) => { + const { id, name, tag, target, ttl_sec } = domainRecordParams; + return ( + + ); + }, + title: '', + }, + ], + data: props.domainRecords.filter(typeEq('CAA')), + link: () => createLink('Add a CAA Record', handlers.openForCreateCAARecord), + order: 'asc', + orderBy: 'name', + title: 'CAA Record', + }, +]; diff --git a/packages/manager/src/features/Domains/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainRecords.tsx deleted file mode 100644 index b7e12400ef2..00000000000 --- a/packages/manager/src/features/Domains/DomainRecords.tsx +++ /dev/null @@ -1,889 +0,0 @@ -import { deleteDomainRecord } from '@linode/api-v4/lib/domains'; -import { Button, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; -import { - compose, - equals, - filter, - flatten, - isEmpty, - lensPath, - over, - pathOr, - prepend, - propEq, -} from 'ramda'; -import * as React from 'react'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -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'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow'; -import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { - getAPIErrorOrDefault, - getErrorStringOrDefault, -} from 'src/utilities/errorUtils'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { storage } from 'src/utilities/storage'; -import { truncateEnd } from 'src/utilities/truncate'; - -import { DomainRecordActionMenu } from './DomainRecordActionMenu'; -import { DomainRecordDrawer } from './DomainRecordDrawer'; -import { StyledDiv, StyledGrid, StyledTableCell } from './DomainRecords.styles'; - -import type { - Domain, - DomainRecord, - DomainType, - RecordType, - UpdateDomainPayload, -} from '@linode/api-v4/lib/domains'; -import type { APIError } from '@linode/api-v4/lib/types'; - -interface UpdateDomainDataProps extends UpdateDomainPayload { - id: number; -} - -interface Props { - domain: Domain; - domainRecords: DomainRecord[]; - updateDomain: (data: UpdateDomainDataProps) => Promise; - updateRecords: () => void; -} - -interface ConfirmationState { - errors?: APIError[]; - open: boolean; - recordId?: number; - submitting: boolean; -} - -interface DrawerState { - fields?: Partial | Partial; - mode: 'create' | 'edit'; - open: boolean; - type: DomainType | RecordType; -} - -interface State { - confirmDialog: ConfirmationState; - drawer: DrawerState; - types: IType[]; -} - -interface IType { - columns: { - render: (r: Domain | DomainRecord) => JSX.Element | null | string; - title: string; - }[]; - data: any[]; - link?: () => JSX.Element | null; - order: 'asc' | 'desc'; - orderBy: 'domain' | 'name' | 'target'; - title: string; -} - -const createLink = (title: string, handler: () => void) => ( - -); - -class DomainRecords extends React.Component { - static defaultDrawerState: DrawerState = { - mode: 'create', - open: false, - type: 'NS', - }; - - confirmDeletion = (recordId: number) => - this.updateConfirmDialog((confirmDialog) => ({ - ...confirmDialog, - open: true, - recordId, - })); - - deleteDomainRecord = () => { - const { - domain: { id: domainId }, - } = this.props; - const { - confirmDialog: { recordId }, - } = this.state; - if (!domainId || !recordId) { - return; - } - - this.updateConfirmDialog((c) => ({ - ...c, - errors: undefined, - submitting: true, - })); - - deleteDomainRecord(domainId, recordId) - .then(() => { - this.props.updateRecords(); - - this.updateConfirmDialog((_) => ({ - errors: undefined, - open: false, - recordId: undefined, - submitting: false, - })); - }) - .catch((errorResponse) => { - const errors = getAPIErrorOrDefault(errorResponse); - this.updateConfirmDialog((c) => ({ - ...c, - errors, - submitting: false, - })); - }); - this.updateConfirmDialog((c) => ({ ...c, submitting: true })); - }; - - generateTypes = (): IType[] => [ - /** SOA Record */ - { - columns: [ - { - render: (d: Domain) => d.domain, - title: 'Primary Domain', - }, - { - render: (d: Domain) => d.soa_email, - title: 'Email', - }, - { - render: compose(msToReadable, pathOr(0, ['ttl_sec'])), - title: 'Default TTL', - }, - { - render: compose(msToReadable, pathOr(0, ['refresh_sec'])), - title: 'Refresh Rate', - }, - { - render: compose(msToReadable, pathOr(0, ['retry_sec'])), - title: 'Retry Rate', - }, - { - render: compose(msToReadable, pathOr(0, ['expire_sec'])), - title: 'Expire Time', - }, - { - render: (d: Domain) => { - return d.type === 'master' ? ( - - ) : null; - }, - title: '', - }, - ], - data: [this.props.domain], - order: 'asc', - orderBy: 'domain', - title: 'SOA Record', - }, - - /** NS Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.target, - title: 'Name Server', - }, - { - render: (r: DomainRecord) => { - const sd = r.name; - const { - domain: { domain }, - } = this.props; - return isEmpty(sd) ? domain : `${sd}.${domain}`; - }, - title: 'Subdomain', - }, - { - render: getTTL, - title: 'TTL', - }, - { - /** - * If the NS is one of Linode's, don't display the Action menu since the user - * cannot make changes to Linode's nameservers. - */ - render: ({ id, name, target, ttl_sec }: DomainRecord) => - id === -1 ? null : ( - - ), - title: '', - }, - ], - data: getNSRecords(this.props), - link: () => createLink('Add an NS Record', this.openForCreateNSRecord), - order: 'asc', - orderBy: 'target', - title: 'NS Record', - }, - - /** MX Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.target, - title: 'Mail Server', - }, - { - render: (r: DomainRecord) => String(r.priority), - title: 'Preference', - }, - { - render: (r: DomainRecord) => r.name, - title: 'Subdomain', - }, - { - render: getTTL, - title: 'TTL', - }, - { - render: ({ id, name, priority, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('MX')), - link: () => createLink('Add a MX Record', this.openForCreateMXRecord), - order: 'asc', - orderBy: 'target', - title: 'MX Record', - }, - - /** A/AAAA Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.name || this.props.domain.domain, - title: 'Hostname', - }, - { render: (r: DomainRecord) => r.target, title: 'IP Address' }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter( - (r) => typeEq('AAAA', r) || typeEq('A', r) - ), - link: () => createLink('Add an A/AAAA Record', this.openForCreateARecord), - order: 'asc', - orderBy: 'name', - title: 'A/AAAA Record', - }, - - /** CNAME Record */ - { - columns: [ - { render: (r: DomainRecord) => r.name, title: 'Hostname' }, - { render: (r: DomainRecord) => r.target, title: 'Aliases to' }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('CNAME')), - link: () => - createLink('Add a CNAME Record', this.openForCreateCNAMERecord), - order: 'asc', - orderBy: 'name', - title: 'CNAME Record', - }, - - /** TXT Record */ - { - columns: [ - { - render: (r: DomainRecord) => r.name || this.props.domain.domain, - title: 'Hostname', - }, - { - render: (r: DomainRecord) => truncateEnd(r.target, 100), - title: 'Value', - }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('TXT')), - link: () => createLink('Add a TXT Record', this.openForCreateTXTRecord), - order: 'asc', - orderBy: 'name', - title: 'TXT Record', - }, - /** SRV Record */ - { - columns: [ - { render: (r: DomainRecord) => r.name, title: 'Service/Protocol' }, - { - render: () => this.props.domain.domain, - title: 'Name', - }, - { - render: (r: DomainRecord) => String(r.priority), - title: 'Priority', - }, - { - render: (r: DomainRecord) => String(r.weight), - title: 'Weight', - }, - { render: (r: DomainRecord) => String(r.port), title: 'Port' }, - { render: (r: DomainRecord) => r.target, title: 'Target' }, - { render: getTTL, title: 'TTL' }, - { - render: ({ - id, - port, - priority, - protocol, - service, - target, - weight, - }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('SRV')), - link: () => createLink('Add an SRV Record', this.openForCreateSRVRecord), - order: 'asc', - orderBy: 'name', - title: 'SRV Record', - }, - - /** CAA Record */ - { - columns: [ - { render: (r: DomainRecord) => r.name, title: 'Name' }, - { render: (r: DomainRecord) => r.tag, title: 'Tag' }, - { - render: (r: DomainRecord) => r.target, - title: 'Value', - }, - { render: getTTL, title: 'TTL' }, - { - render: ({ id, name, tag, target, ttl_sec }: DomainRecord) => ( - - ), - title: '', - }, - ], - data: this.props.domainRecords.filter(typeEq('CAA')), - link: () => createLink('Add a CAA Record', this.openForCreateCAARecord), - order: 'asc', - orderBy: 'name', - title: 'CAA Record', - }, - ]; - - handleCloseDialog = () => { - this.updateConfirmDialog(() => ({ - open: false, - recordId: undefined, - submitting: false, - })); - }; - - handleOpenSOADrawer = (d: Domain) => { - return d.type === 'master' - ? this.openForEditPrimaryDomain(d) - : this.openForEditSecondaryDomain(d); - }; - - openForCreateARecord = () => this.openForCreation('AAAA'); - - openForCreateCAARecord = () => this.openForCreation('CAA'); - - openForCreateCNAMERecord = () => this.openForCreation('CNAME'); - openForCreateMXRecord = () => this.openForCreation('MX'); - - openForCreateNSRecord = () => this.openForCreation('NS'); - openForCreateSRVRecord = () => this.openForCreation('SRV'); - - openForCreateTXTRecord = () => this.openForCreation('TXT'); - openForCreation = (type: RecordType) => - this.updateDrawer(() => ({ - mode: 'create', - open: true, - submitting: false, - type, - })); - - openForEditARecord = ( - f: Pick - ) => this.openForEditing('AAAA', f); - openForEditCAARecord = ( - f: Pick - ) => this.openForEditing('CAA', f); - - openForEditCNAMERecord = ( - f: Pick - ) => this.openForEditing('CNAME', f); - openForEditMXRecord = ( - f: Pick - ) => this.openForEditing('MX', f); - - openForEditNSRecord = ( - f: Pick - ) => this.openForEditing('NS', f); - openForEditPrimaryDomain = (f: Partial) => - this.openForEditing('master', f); - - openForEditSRVRecord = ( - f: Pick< - DomainRecord, - 'id' | 'name' | 'port' | 'priority' | 'protocol' | 'target' | 'weight' - > - ) => this.openForEditing('SRV', f); - openForEditSecondaryDomain = (f: Partial) => - this.openForEditing('slave', f); - - openForEditTXTRecord = ( - f: Pick - ) => this.openForEditing('TXT', f); - - openForEditing = ( - type: DomainType | RecordType, - fields: Partial | Partial - ) => - this.updateDrawer(() => ({ - fields, - mode: 'edit', - open: true, - submitting: false, - type, - })); - - renderDialogActions = () => { - return ( - - ); - }; - - resetDrawer = () => this.updateDrawer(() => DomainRecords.defaultDrawerState); - - updateConfirmDialog = (fn: (d: ConfirmationState) => ConfirmationState) => - this.setState(over(lensPath(['confirmDialog']), fn), () => { - scrollErrorIntoView(); - }); - - updateDrawer = (fn: (d: DrawerState) => DrawerState) => - this.setState(over(lensPath(['drawer']), fn)); - - constructor(props: Props) { - super(props); - this.state = { - confirmDialog: { - open: false, - submitting: false, - }, - drawer: DomainRecords.defaultDrawerState, - types: this.generateTypes(), - }; - } - - componentDidUpdate(prevProps: Props) { - if ( - !equals(prevProps.domainRecords, this.props.domainRecords) || - !equals(prevProps.domain, this.props.domain) - ) { - this.setState({ types: this.generateTypes() }); - } - } - - render() { - const { domain, domainRecords } = this.props; - const { confirmDialog, drawer } = this.state; - - return ( - <> - - {this.state.types.map((type, eachTypeIdx) => { - const ref: React.Ref = React.createRef(); - - return ( -
    - - - - {type.title} - - - {type.link && ( - - {' '} - {type.link()}{' '} - - )} - - - {({ data: orderedData }) => { - return ( - - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => { - return ( - <> - - - - {type.columns.length > 0 && - type.columns.map((col, columnIndex) => { - return ( - - {col.title} - - ); - })} - - - - {type.data.length === 0 ? ( - - ) : ( - paginatedData.map((data, idx) => { - return ( - - {type.columns.length > 0 && - type.columns.map( - ( - { render, title }, - columnIndex - ) => { - return ( - - {render(data)} - - ); - } - )} - - ); - }) - )} - -
    - - - ); - }} -
    - ); - }} -
    -
    - ); - })} - - Are you sure you want to delete this record? - - - - ); - } -} - -const msToReadable = (v: number): null | string => - pathOr(null, [v], { - 0: 'Default', - 30: '30 seconds', - 120: '2 minutes', - 300: '5 minutes', - 3600: '1 hour', - 7200: '2 hours', - 14400: '4 hours', - 28800: '8 hours', - 57600: '16 hours', - 86400: '1 day', - 172800: '2 days', - 345600: '4 days', - 604800: '1 week', - 1209600: '2 weeks', - 2419200: '4 weeks', - }); - -const getTTL = compose(msToReadable, pathOr(0, ['ttl_sec'])); - -const typeEq = propEq('type'); - -const prependLinodeNS = compose( - flatten, - prepend([ - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns1.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns2.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns3.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns4.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - { - id: -1, - name: '', - port: 0, - priority: 0, - protocol: null, - service: null, - tag: null, - target: 'ns5.linode.com', - ttl_sec: 0, - type: 'NS', - weight: 0, - }, - ]) -); - -const getNSRecords = compose< - Props, - DomainRecord[], - DomainRecord[], - DomainRecord[] ->(prependLinodeNS, filter(typeEq('NS')), pathOr([], ['domainRecords'])); - -export default DomainRecords; From 853c33e54c5eb9d4216eaa118ca6dacca46da20b Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Mon, 13 Jan 2025 18:09:22 +0530 Subject: [PATCH 08/42] refactor: [M3-9031] - Replace one-off hardcoded color values with color tokens pt5 (#11488) * Replace hardcoded color values with color tokens pt5 * Added changeset: Replace one-off hardcoded color values with color tokens pt5 * Revert back the color replacement of `#00000010` --- .../.changeset/pr-11488-tech-stories-1736320081038.md | 5 +++++ .../EnhancedSelect/components/DropdownIndicator.tsx | 4 ++-- .../manager/src/components/Uploaders/FileUpload.styles.ts | 4 +++- .../components/Uploaders/ImageUploader/ImageUploader.tsx | 2 +- .../KubeCheckoutBar/KubeCheckoutSummary.styles.ts | 6 +++--- .../Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx | 6 +++--- .../Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx | 8 ++++---- .../NodeBalancerSummary/TablesPanel.tsx | 6 +++--- .../PhoneVerification/PhoneVerification.tsx | 6 +++--- 9 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 packages/manager/.changeset/pr-11488-tech-stories-1736320081038.md diff --git a/packages/manager/.changeset/pr-11488-tech-stories-1736320081038.md b/packages/manager/.changeset/pr-11488-tech-stories-1736320081038.md new file mode 100644 index 00000000000..b99c76299b8 --- /dev/null +++ b/packages/manager/.changeset/pr-11488-tech-stories-1736320081038.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace one-off hardcoded color values with color tokens pt5 ([#11488](https://github.com/linode/manager/pull/11488)) diff --git a/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx b/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx index d6bf5f480a3..8d5c3b48d22 100644 --- a/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/DropdownIndicator.tsx @@ -6,8 +6,8 @@ export const DropdownIndicator = () => { return ; }; -const StyledKeyboardArrowDown = styled(KeyboardArrowDown)(() => ({ - color: '#aaa !important', +const StyledKeyboardArrowDown = styled(KeyboardArrowDown)(({ theme }) => ({ + color: `${theme.tokens.color.Neutrals[50]} !important`, height: 28, marginRight: '4px', marginTop: 0, diff --git a/packages/manager/src/components/Uploaders/FileUpload.styles.ts b/packages/manager/src/components/Uploaders/FileUpload.styles.ts index 8d0ac7bb2a5..24c89059e8d 100644 --- a/packages/manager/src/components/Uploaders/FileUpload.styles.ts +++ b/packages/manager/src/components/Uploaders/FileUpload.styles.ts @@ -73,7 +73,9 @@ export const StyledActionsContainer = styled('div', { export const useStyles = makeStyles()((theme: Theme) => ({ barColorPrimary: { backgroundColor: - theme.name === 'light' ? theme.tokens.color.Brand[30] : '#243142', + theme.name === 'light' + ? theme.tokens.color.Brand[30] + : theme.tokens.color.Brand[100], }, error: { '& g': { diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 9386d2150fa..6c70707cd20 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -96,7 +96,7 @@ export const ImageUploader = React.memo((props: Props) => { }); const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ - borderColor: 'gray', + borderColor: theme.tokens.color.Neutrals[60], borderStyle: 'dashed', borderWidth: 1, display: 'flex', diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts index 12ac83ef280..6c38e3e84d8 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutSummary.styles.ts @@ -28,12 +28,12 @@ export const StyledNodePoolSummaryBox = styled(Box, { export const StyledIconButton = styled(IconButton, { label: 'StyledIconButton', -})(() => ({ +})(({ theme }) => ({ '&:hover': { - color: '#6e6e6e', + color: theme.tokens.color.Neutrals[70], }, alignItems: 'flex-start', - color: '#979797', + color: theme.tokens.color.Neutrals[60], marginTop: -4, padding: 0, })); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index 1988be4bf87..2d6c04a1538 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -122,7 +122,7 @@ const LinodeSummary = (props: Props) => { { data: metrics, format: formatPercentage, - legendColor: 'blue', + legendColor: theme.graphs.blue, legendTitle: 'CPU %', }, ]} @@ -170,13 +170,13 @@ const LinodeSummary = (props: Props) => { { data: getMetrics(data.io), format: formatNumber, - legendColor: 'yellow', + legendColor: theme.graphs.yellow, legendTitle: 'I/O Rate', }, { data: getMetrics(data.swap), format: formatNumber, - legendColor: 'red', + legendColor: theme.graphs.red, legendTitle: 'Swap Rate', }, ]} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx index 01fc5940101..3110c229b8d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/NetworkGraphs.tsx @@ -214,25 +214,25 @@ const Graph = (props: GraphProps) => { { data: metrics.publicIn, format, - legendColor: 'darkGreen', + legendColor: theme.graphs.darkGreen, legendTitle: 'Public In', }, { data: metrics.publicOut, format, - legendColor: 'lightGreen', + legendColor: theme.graphs.lightGreen, legendTitle: 'Public Out', }, { data: metrics.privateIn, format, - legendColor: 'purple', + legendColor: theme.graphs.purple, legendTitle: 'Private In', }, { data: metrics.privateOut, format, - legendColor: 'yellow', + legendColor: theme.graphs.yellow, legendTitle: 'Private Out', }, ]} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index 016bbb7d2f9..d7708c40cb4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -107,7 +107,7 @@ export const TablesPanel = () => { { data: metrics, format: formatNumber, - legendColor: 'purple', + legendColor: theme.graphs.purple, legendTitle: 'Connections', }, ]} @@ -195,13 +195,13 @@ export const TablesPanel = () => { { data: getMetrics(trafficIn), format: formatBitsPerSecond, - legendColor: 'darkGreen', + legendColor: theme.graphs.darkGreen, legendTitle: 'Traffic In', }, { data: getMetrics(trafficOut), format: formatBitsPerSecond, - legendColor: 'lightGreen', + legendColor: theme.graphs.lightGreen, legendTitle: 'Traffic Out', }, ]} diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index cb8a78ce1ad..e6cac186449 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -274,13 +274,13 @@ export const PhoneVerification = ({ }))} slotProps={{ paper: { - sx: { - border: '1px solid #3683dc', + sx: (theme) => ({ + border: `1px solid ${theme.tokens.color.Ultramarine[80]}`, maxHeight: '285px', overflow: 'hidden', textWrap: 'nowrap', width: 'fit-content', - }, + }), }, }} textFieldProps={{ From 02c975d57a886a90fb6a0b2966d1f42d581deb1f Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Mon, 13 Jan 2025 14:39:13 -0500 Subject: [PATCH 09/42] test: [M3-9066] - Remove Cypress deprecated helper functions (#11501) * Remove dedrecated Cypress helper functions * Added changeset: Remove cypress deprecated helper.ts functions --- docs/development-guide/08-testing.md | 8 +- .../pr-11501-tests-1736539585978.md | 5 + .../e2e/core/account/oauth-apps.spec.ts | 33 +- .../core/domains/smoke-clone-domain.spec.ts | 14 +- .../core/domains/smoke-create-domain.spec.ts | 15 +- .../core/domains/smoke-delete-domain.spec.ts | 3 +- .../smoke-domain-download-zone-file.spec.ts | 5 +- .../smoke-domain-import-a-zone.spec.ts | 28 +- .../core/firewalls/delete-firewall.spec.ts | 9 +- .../core/firewalls/update-firewall.spec.ts | 18 +- .../images/create-linode-from-image.spec.ts | 11 +- .../e2e/core/linodes/create-linode.spec.ts | 43 ++- .../smoke-linode-landing-table.spec.ts | 308 +++++++++++------- .../notifications.spec.ts | 5 +- packages/manager/cypress/support/helpers.ts | 84 ----- 15 files changed, 302 insertions(+), 287 deletions(-) create mode 100644 packages/manager/.changeset/pr-11501-tests-1736539585978.md delete mode 100644 packages/manager/cypress/support/helpers.ts diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index a97bf29a06c..0581601ac73 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -281,17 +281,15 @@ Environment variables that can be used to improve test performance in some scena cy.wait('@getProfilePreferences'); cy.wait('@getAccountSettings'); - /* `getVisible` defined in /cypress/support/helpers.ts - plus a few other commonly used commands shortened as methods */ - getVisible(`tr[data-qa-linode="${label}"]`).within(() => { + cy.get(`tr[data-qa-linode="${label}"]`).should('be.visible').within(() => { // use `within` to search inside/use data from/assert on a specific page element cy.get(`[data-qa-ip-main]`) // `realHover` and more real event methods from cypress real events plugin .realHover() .then(() => { - getVisible(`[aria-label="Copy ${ip} to clipboard"]`); + cy.get(`[aria-label="Copy ${ip} to clipboard"]`).should('be.visible'); }); - getVisible(`[aria-label="Action menu for Linode ${label}"]`); + cy.get(`[aria-label="Action menu for Linode ${label}"]`).should('be.visible'); }); // `findByText` and others from cypress testing library plugin cy.findByText('Oh Snap!', { timeout: 1000 }).should('not.exist'); diff --git a/packages/manager/.changeset/pr-11501-tests-1736539585978.md b/packages/manager/.changeset/pr-11501-tests-1736539585978.md new file mode 100644 index 00000000000..bf7bf3b6e1b --- /dev/null +++ b/packages/manager/.changeset/pr-11501-tests-1736539585978.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Remove cypress deprecated helper.ts functions ([#11501](https://github.com/linode/manager/pull/11501)) diff --git a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts index 1253783f7af..cef3136b233 100644 --- a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts @@ -1,4 +1,3 @@ -import { fbltClick } from 'support/helpers'; import { oauthClientFactory } from '@src/factories'; import { mockCreateOAuthApp, @@ -31,8 +30,11 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - fbltClick('Label').clear().type(oauthApp.label); - fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click().clear().type(oauthApp.label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(oauthApp.redirect_uri); ui.buttonGroup .findButtonByTitle('Cancel') .should('be.visible') @@ -54,8 +56,11 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - fbltClick('Label').clear().type(oauthApp.label); - fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click().clear().type(oauthApp.label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(oauthApp.redirect_uri); }); ui.drawerCloseButton.find().click(); @@ -75,8 +80,8 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .should('be.visible') .within(() => { // An error message appears when attempting to create an OAuth App without a label - fbltClick('Label').clear(); - fbltClick('Callback URL').clear(); + cy.findByLabelText('Label').click().clear(); + cy.findByLabelText('Callback URL').click().clear(); ui.button .findByTitle('Create') .should('be.visible') @@ -86,8 +91,11 @@ const createOAuthApp = (oauthApp: OAuthClient) => { cy.findByText('Redirect URI is required.'); // Fill out and submit OAuth App create form. - fbltClick('Label').clear().type(oauthApp.label); - fbltClick('Callback URL').clear().type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click().clear().type(oauthApp.label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(oauthApp.redirect_uri); // Check the 'public' checkbox if (oauthApp.public) { cy.get('[data-qa-checked]').should('be.visible').click(); @@ -312,8 +320,11 @@ describe('OAuth Apps', () => { .should('be.visible') .should('be.disabled'); - fbltClick('Label').clear().type(updatedApps[0].label); - fbltClick('Callback URL').clear().type(updatedApps[0].label); + cy.findByLabelText('Label').click().clear().type(updatedApps[0].label); + cy.findByLabelText('Callback URL') + .click() + .clear() + .type(updatedApps[0].label); ui.buttonGroup .findButtonByTitle('Save Changes') diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts index f379f8c35a5..2b3903f78de 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -1,6 +1,5 @@ import { Domain } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; -import { getClick, fbtClick, fbltClick } from 'support/helpers'; import { authenticate } from 'support/api/authentication'; import { randomDomainName } from 'support/util/random'; import { createDomain } from '@linode/api-v4/lib/domains'; @@ -44,11 +43,11 @@ describe('Clone a Domain', () => { domainRecords.forEach((rec) => { interceptCreateDomainRecord().as('apiCreateRecord'); - fbtClick(rec.name); + cy.findByText(rec.name).click(); rec.fields.forEach((f) => { - getClick(f.name).type(f.value); + cy.get(f.name).click().type(f.value); }); - fbtClick('Save'); + cy.findByText('Save').click(); cy.wait('@apiCreateRecord'); }); @@ -102,7 +101,7 @@ describe('Clone a Domain', () => { .should('be.disabled'); // Confirm that an error is displayed when entering an invalid domain name - fbltClick('New Domain').type(invalidDomainName); + cy.findByLabelText('New Domain').click().type(invalidDomainName); ui.buttonGroup .findButtonByTitle('Create Domain') .should('be.visible') @@ -110,7 +109,10 @@ describe('Clone a Domain', () => { .click(); cy.findByText('Domain is not valid.').should('be.visible'); - fbltClick('New Domain').clear().type(clonedDomainName); + cy.findByLabelText('New Domain') + .click() + .clear() + .type(clonedDomainName); ui.buttonGroup .findButtonByTitle('Create Domain') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts index e3d8d513de1..9f9e832cc58 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts @@ -1,7 +1,6 @@ import { Domain } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { fbtClick, getClick, getVisible } from 'support/helpers'; import { interceptCreateDomain, mockGetDomains, @@ -30,13 +29,15 @@ describe('Create a Domain', () => { interceptCreateDomain().as('createDomain'); cy.visitWithLogin('/domains'); cy.wait('@getDomains'); - fbtClick('Create Domain'); + cy.findByText('Create Domain').click(); const label = randomDomainName(); - getVisible('[id="domain"][data-testid="textfield-input"]').type(label); - getVisible('[id="soa-email-address"][data-testid="textfield-input"]').type( - 'devs@linode.com' - ); - getClick('[data-testid="submit"]'); + cy.get('[id="domain"][data-testid="textfield-input"]') + .should('be.visible') + .type(label); + cy.get('[id="soa-email-address"][data-testid="textfield-input"]') + .should('be.visible') + .type('devs@linode.com'); + cy.get('[data-testid="submit"]').click(); cy.wait('@createDomain'); cy.get('[data-qa-header]').should('contain', label); }); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts index 0e4710ec621..c6bdd8b30ae 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts @@ -1,6 +1,5 @@ import { Domain } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; -import { containsClick } from 'support/helpers'; import { authenticate } from 'support/api/authentication'; import { randomDomainName } from 'support/util/random'; import { createDomain } from '@linode/api-v4/lib/domains'; @@ -73,8 +72,8 @@ describe('Delete a Domain', () => { .findButtonByTitle('Delete Domain') .should('be.visible') .should('be.disabled'); + cy.contains('Domain Name').click().type(domain.domain); - containsClick('Domain Name').type(domain.domain); ui.buttonGroup .findButtonByTitle('Delete Domain') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts index 951516fecf3..4c5401df232 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts @@ -4,7 +4,6 @@ import { domainZoneFileFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { fbtClick, fbtVisible } from 'support/helpers'; import { mockGetDomains, mockGetDomain, @@ -46,8 +45,8 @@ describe('Download a Zone file', () => { mockGetDomain(mockDomain.id, mockDomain).as('getDomain'); mockGetDomainRecords([mockDomainRecords]).as('getDomainRecords'); - fbtVisible(mockDomain.domain); - fbtClick(mockDomain.domain); + cy.findByText(mockDomain.domain).should('be.visible').should('be.visible'); + cy.findByText(mockDomain.domain).click(); cy.wait('@getDomain'); cy.wait('@getDomainRecords'); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts index 54a1cc8b439..17587c4f1ae 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts @@ -1,7 +1,6 @@ import { ImportZonePayload } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { fbltClick } from 'support/helpers'; import { randomDomainName, randomIp } from 'support/util/random'; import { mockGetDomains, mockImportDomain } from 'support/intercepts/domains'; import { ui } from 'support/ui'; @@ -46,7 +45,7 @@ describe('Import a Zone', () => { .should('be.disabled'); // Verify only filling out Domain cannot import - fbltClick('Domain').clear().type(zone.domain); + cy.findByLabelText('Domain').click().clear().type(zone.domain); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -55,8 +54,11 @@ describe('Import a Zone', () => { cy.findByText('Remote nameserver is required.'); // Verify invalid domain cannot import - fbltClick('Domain').clear().type('1'); - fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); + cy.findByLabelText('Domain').click().clear().type('1'); + cy.findByLabelText('Remote Nameserver') + .click() + .clear() + .type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -65,8 +67,11 @@ describe('Import a Zone', () => { cy.findByText('Domain is not valid.'); // Verify only filling out RemoteNameserver cannot import - fbltClick('Domain').clear(); - fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); + cy.findByLabelText('Domain').click().clear(); + cy.findByLabelText('Remote Nameserver') + .click() + .clear() + .type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -75,8 +80,8 @@ describe('Import a Zone', () => { cy.findByText('Domain is required.'); // Verify invalid remote nameserver cannot import - fbltClick('Domain').clear().type(zone.domain); - fbltClick('Remote Nameserver').clear().type('1'); + cy.findByLabelText('Domain').click().clear().type(zone.domain); + cy.findByLabelText('Remote Nameserver').click().clear().type('1'); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -87,8 +92,11 @@ describe('Import a Zone', () => { // Fill out and import the zone. mockImportDomain(mockDomain).as('importDomain'); mockGetDomains([mockDomain]).as('getDomains'); - fbltClick('Domain').clear().type(zone.domain); - fbltClick('Remote Nameserver').clear().type(zone.remote_nameserver); + cy.findByLabelText('Domain').click().clear().type(zone.domain); + cy.findByLabelText('Remote Nameserver') + .click() + .clear() + .type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts index 7be2db7cd11..8f55e035d5b 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -3,7 +3,6 @@ import { firewallFactory } from 'src/factories/firewalls'; import { authenticate } from 'support/api/authentication'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; -import { fbtVisible, fbtClick } from 'support/helpers'; import { cleanUp } from 'support/util/cleanup'; authenticate(); @@ -35,8 +34,8 @@ describe('delete firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Delete'); - fbtClick('Delete'); + cy.findByText('Delete').should('be.visible'); + cy.findByText('Delete').click(); }); // Cancel deletion when prompted to confirm. @@ -56,8 +55,8 @@ describe('delete firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Delete'); - fbtClick('Delete'); + cy.findByText('Delete').should('be.visible'); + cy.findByText('Delete').click(); }); // Confirm deletion. diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index a76d5ee8a09..3aa89d01e85 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -14,13 +14,11 @@ import { firewallRulesFactory, } from 'src/factories'; import { authenticate } from 'support/api/authentication'; -import { containsClick } from 'support/helpers'; import { interceptUpdateFirewallLinodes, interceptUpdateFirewallRules, } from 'support/intercepts/firewalls'; import { randomItem, randomString, randomLabel } from 'support/util/random'; -import { fbtVisible, fbtClick } from 'support/helpers'; import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; @@ -84,11 +82,13 @@ const addFirewallRules = (rule: FirewallRuleType, direction: string) => { const description = rule.description ? rule.description : 'test-description'; - containsClick('Label').type('{selectall}{backspace}' + label); - containsClick('Description').type(description); + cy.contains('Label') + .click() + .type('{selectall}{backspace}' + label); + cy.contains('Description').click().type(description); const action = rule.action ? getRuleActionLabel(rule.action) : 'Accept'; - containsClick(action).click(); + cy.contains(action).click(); ui.button .findByTitle('Add Rule') @@ -346,8 +346,8 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Disable'); - fbtClick('Disable'); + cy.findByText('Disable').should('be.visible'); + cy.findByText('Disable').click(); }); ui.dialog @@ -375,8 +375,8 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - fbtVisible('Enable'); - fbtClick('Enable'); + cy.findByText('Enable').should('be.visible'); + cy.findByText('Enable').click(); }); ui.dialog diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index d2799cb7419..da4f61f4efb 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -1,4 +1,3 @@ -import { fbtClick, fbtVisible, getClick } from 'support/helpers'; import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { mockGetAllImages } from 'support/intercepts/images'; @@ -49,8 +48,8 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId(region.id).click(); - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"][type="radio"]'); + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"][type="radio"]').click(); cy.get('[id="root-password"]').type(randomString(32)); ui.button @@ -62,9 +61,9 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.wait('@mockLinodeRequest'); - fbtVisible(mockLinode.label); - fbtVisible(region.label); - fbtVisible(`${mockLinode.id}`); + cy.findByText(mockLinode.label).should('be.visible'); + cy.findByText(region.label).should('be.visible'); + cy.findByText(`${mockLinode.id}`).should('be.visible'); }; describe('create linode from image, mocked data', () => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 37e5309715d..467fa122445 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -40,13 +40,6 @@ import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - fbtClick, - fbtVisible, - getClick, - getVisible, - containsVisible, -} from 'support/helpers'; let username: string; @@ -374,23 +367,27 @@ describe('Create Linode', () => { // Verify VPCs get fetched once a region is selected cy.wait('@getVPCs'); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + cy.findByText('Shared CPU').click(); + cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); // the "VPC" section is present, and the VPC in the same region of // the linode can be selected. - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible('Assign this Linode to an existing VPC.'); - // select VPC - cy.findByLabelText('Assign VPC') - .should('be.visible') - .focus() - .type(`${mockVPC.label}{downArrow}{enter}`); - // select subnet - cy.findByPlaceholderText('Select Subnet') - .should('be.visible') - .type(`${mockSubnet.label}{downArrow}{enter}`); - }); + cy.get('[data-testid="vpc-panel"]') + .should('be.visible') + .within(() => { + cy.contains('Assign this Linode to an existing VPC.').should( + 'be.visible' + ); + // select VPC + cy.findByLabelText('Assign VPC') + .should('be.visible') + .focus() + .type(`${mockVPC.label}{downArrow}{enter}`); + // select subnet + cy.findByPlaceholderText('Select Subnet') + .should('be.visible') + .type(`${mockSubnet.label}{downArrow}{enter}`); + }); // The drawer opens when clicking "Add an SSH Key" button ui.button @@ -430,13 +427,13 @@ describe('Create Linode', () => { // When a user creates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user cy.findByText(sshPublicKeyLabel, { exact: false }).should('be.visible'); - getClick('#linode-label').clear().type(linodeLabel); + cy.get('#linode-label').clear().type(linodeLabel).click(); cy.get('#root-password').type(rootpass); ui.button.findByTitle('Create Linode').click(); cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); + cy.findByText(linodeLabel).should('be.visible'); cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index e1bbfeb6e7b..91d28a545ab 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -3,12 +3,6 @@ import { Linode } from '@linode/api-v4'; import { accountSettingsFactory } from '@src/factories/accountSettings'; import { linodeFactory } from '@src/factories/linodes'; import { makeResourcePage } from '@src/mocks/serverHandlers'; -import { - containsVisible, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; import { ui } from 'support/ui'; import { routes } from 'support/ui/constants'; import { apiMatcher } from 'support/util/intercepts'; @@ -83,37 +77,65 @@ describe('linode landing checks', () => { }); it('checks the landing page side menu items', () => { - getVisible('[title="Akamai - Dashboard"][href="/dashboard"]'); - getVisible('[data-testid="menu-item-Linodes"][href="/linodes"]'); - getVisible('[data-testid="menu-item-Volumes"][href="/volumes"]'); - getVisible( + cy.get('[title="Akamai - Dashboard"][href="/dashboard"]').should( + 'be.visible' + ); + cy.get('[data-testid="menu-item-Linodes"][href="/linodes"]').should( + 'be.visible' + ); + cy.get('[data-testid="menu-item-Volumes"][href="/volumes"]').should( + 'be.visible' + ); + cy.get( '[data-testid="menu-item-NodeBalancers"][href="/nodebalancers"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Firewalls"][href="/firewalls"]').should( + 'be.visible' ); - getVisible('[data-testid="menu-item-Firewalls"][href="/firewalls"]'); - getVisible('[data-testid="menu-item-StackScripts"][href="/stackscripts"]'); - getVisible('[data-testid="menu-item-Images"][href="/images"]'); - getVisible('[data-testid="menu-item-Domains"][href="/domains"]'); - getVisible( - '[data-testid="menu-item-Kubernetes"][href="/kubernetes/clusters"]' + cy.get( + '[data-testid="menu-item-StackScripts"][href="/stackscripts"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Images"][href="/images"]').should( + 'be.visible' ); - getVisible( + cy.get('[data-testid="menu-item-Domains"][href="/domains"]').should( + 'be.visible' + ); + cy.get( + '[data-testid="menu-item-Kubernetes"][href="/kubernetes/clusters"]' + ).should('be.visible'); + cy.get( '[data-testid="menu-item-Object Storage"][href="/object-storage/buckets"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Longview"][href="/longview"]').should( + 'be.visible' ); - getVisible('[data-testid="menu-item-Longview"][href="/longview"]'); - getVisible( + cy.get( '[data-testid="menu-item-Marketplace"][href="/linodes/create?type=One-Click"]' + ).should('be.visible'); + cy.get('[data-testid="menu-item-Account"][href="/account"]').should( + 'be.visible' + ); + cy.get('[data-testid="menu-item-Help & Support"][href="/support"]').should( + 'be.visible' ); - getVisible('[data-testid="menu-item-Account"][href="/account"]'); - getVisible('[data-testid="menu-item-Help & Support"][href="/support"]'); }); it('checks the landing top menu items', () => { cy.wait('@getProfile').then((xhr) => { const username = xhr.response?.body.username; - getVisible('[aria-label="open menu"]'); - getVisible('[data-qa-add-new-menu-button="true"]'); - getVisible('[data-qa-search-icon="true"]'); - fbtVisible('Search Products, IP Addresses, Tags...'); + cy.get('[aria-label="open menu"]') + .should('be.visible') + .should('be.visible'); + cy.get('[data-qa-add-new-menu-button="true"]') + .should('be.visible') + .should('be.visible'); + cy.get('[data-qa-search-icon="true"]') + .should('be.visible') + .should('be.visible'); + cy.findByText('Search Products, IP Addresses, Tags...').should( + 'be.visible' + ); cy.findByLabelText('Help & Support') .should('be.visible') @@ -130,17 +152,21 @@ describe('linode landing checks', () => { .should('be.visible') .should('be.enabled'); - getVisible('[aria-label="Notifications"]'); - getVisible('[data-testid="nav-group-profile"]').within(() => { - fbtVisible(username); - }); + cy.get('[aria-label="Notifications"]').should('be.visible'); + cy.get('[data-testid="nav-group-profile"]') + .should('be.visible') + .within(() => { + cy.findByText(username).should('be.visible'); + }); }); }); it('checks the landing labels and buttons', () => { - getVisible('h1[data-qa-header="Linodes"]'); - getVisible('a[aria-label="Docs - link opens in a new tab"]'); - fbtVisible('Create Linode'); + cy.get('h1[data-qa-header="Linodes"]').should('be.visible'); + cy.get('a[aria-label="Docs - link opens in a new tab"]').should( + 'be.visible' + ); + cy.findByText('Create Linode').should('be.visible'); }); it('checks label and region sorting behavior for linode table', () => { @@ -157,113 +183,161 @@ describe('linode landing checks', () => { ).label; const checkFirstRow = (label: string) => { - getVisible('tr[data-qa-loading="true"]') + cy.get('tr[data-qa-loading="true"]') + .should('be.visible') .first() .within(() => { - containsVisible(label); + cy.contains(label).should('be.visible'); }); }; const checkLastRow = (label: string) => { - getVisible('tr[data-qa-loading="true"]') + cy.get('tr[data-qa-loading="true"]') + .should('be.visible') .last() .within(() => { - containsVisible(label); + cy.contains(label).should('be.visible'); }); }; checkFirstRow(firstLinodeLabel); checkLastRow(lastLinodeLabel); - getClick('[aria-label="Sort by label"]'); + cy.get('[aria-label="Sort by label"]').click(); checkFirstRow(lastLinodeLabel); checkLastRow(firstLinodeLabel); - getClick('[aria-label="Sort by region"]'); + cy.get('[aria-label="Sort by region"]').click(); checkFirstRow(firstRegionLabel); checkLastRow(lastRegionLabel); - getClick('[aria-label="Sort by region"]'); + cy.get('[aria-label="Sort by region"]').click(); checkFirstRow(lastRegionLabel); checkLastRow(firstRegionLabel); }); it('checks the create menu dropdown items', () => { - getClick('[data-qa-add-new-menu-button="true"]'); - - getVisible('[aria-labelledby="create-menu"]').within(() => { - getVisible('[href="/linodes/create"]').within(() => { - fbtVisible('Linode'); - fbtVisible('High performance SSD Linux servers'); - }); - - getVisible('[href="/volumes/create"]').within(() => { - fbtVisible('Volume'); - fbtVisible('Attach additional storage to your Linode'); - }); + cy.get('[data-qa-add-new-menu-button="true"]').click(); - getVisible('[href="/nodebalancers/create"]').within(() => { - fbtVisible('NodeBalancer'); - fbtVisible('Ensure your services are highly available'); + cy.get('[aria-labelledby="create-menu"]') + .should('be.visible') + .within(() => { + cy.get('[href="/linodes/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Linode').should('be.visible'); + cy.findByText('High performance SSD Linux servers').should( + 'be.visible' + ); + }); + + cy.get('[href="/volumes/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Volume').should('be.visible'); + cy.findByText('Attach additional storage to your Linode').should( + 'be.visible' + ); + }); + + cy.get('[href="/nodebalancers/create"]') + .should('be.visible') + .within(() => { + cy.findByText('NodeBalancer').should('be.visible'); + cy.findByText('Ensure your services are highly available').should( + 'be.visible' + ); + }); + + cy.get('[href="/firewalls/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Firewall').should('be.visible'); + cy.findByText('Control network access to your Linodes').should( + 'be.visible' + ); + }); + + cy.get('[href="/firewalls/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Firewall').should('be.visible'); + cy.findByText('Control network access to your Linodes').should( + 'be.visible' + ); + }); + + cy.get('[href="/domains/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Domain').should('be.visible'); + cy.findByText('Manage your DNS records').should('be.visible'); + }); + + cy.get('[href="/kubernetes/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Kubernetes').should('be.visible'); + cy.findByText('Highly available container workloads').should( + 'be.visible' + ); + }); + + cy.get('[href="/object-storage/buckets/create"]') + .should('be.visible') + .within(() => { + cy.findByText('Bucket').should('be.visible'); + cy.findByText('S3-compatible object storage').should('be.visible'); + }); + + cy.get('[href="/linodes/create?type=One-Click"]') + .should('be.visible') + .within(() => { + cy.findByText('Marketplace').should('be.visible'); + cy.findByText('Deploy applications with ease').should('be.visible'); + }); }); + }); - getVisible('[href="/firewalls/create"]').within(() => { - fbtVisible('Firewall'); - fbtVisible('Control network access to your Linodes'); - }); + it('checks the table and action menu buttons/labels', () => { + const label = linodeLabel(1); + const ip = mockLinodes[0].ipv4[0]; - getVisible('[href="/firewalls/create"]').within(() => { - fbtVisible('Firewall'); - fbtVisible('Control network access to your Linodes'); + cy.get('[aria-label="Sort by label"]') + .should('be.visible') + .within(() => { + cy.findByText('Label').should('be.visible'); }); - getVisible('[href="/domains/create"]').within(() => { - fbtVisible('Domain'); - fbtVisible('Manage your DNS records'); + cy.get('[aria-label="Sort by _statusPriority"]') + .should('be.visible') + .within(() => { + cy.findByText('Status').should('be.visible'); }); - - getVisible('[href="/kubernetes/create"]').within(() => { - fbtVisible('Kubernetes'); - fbtVisible('Highly available container workloads'); + cy.get('[aria-label="Sort by type"]') + .should('be.visible') + .within(() => { + cy.findByText('Plan').should('be.visible'); }); - - getVisible('[href="/object-storage/buckets/create"]').within(() => { - fbtVisible('Bucket'); - fbtVisible('S3-compatible object storage'); + cy.get('[aria-label="Sort by ipv4[0]"]') + .should('be.visible') + .within(() => { + cy.findByText('Public IP Address').should('be.visible'); }); - getVisible('[href="/linodes/create?type=One-Click"]').within(() => { - fbtVisible('Marketplace'); - fbtVisible('Deploy applications with ease'); + cy.get(`tr[data-qa-linode="${label}"]`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle(ip) + .should('be.visible') + .realHover() + .then(() => { + cy.get(`[aria-label="Copy ${ip} to clipboard"]`).should( + 'be.visible' + ); + }); + cy.get(`[aria-label="Action menu for Linode ${label}"]`).should( + 'be.visible' + ); }); - }); - }); - - it('checks the table and action menu buttons/labels', () => { - const label = linodeLabel(1); - const ip = mockLinodes[0].ipv4[0]; - - getVisible('[aria-label="Sort by label"]').within(() => { - fbtVisible('Label'); - }); - - getVisible('[aria-label="Sort by _statusPriority"]').within(() => { - fbtVisible('Status'); - }); - getVisible('[aria-label="Sort by type"]').within(() => { - fbtVisible('Plan'); - }); - getVisible('[aria-label="Sort by ipv4[0]"]').within(() => { - fbtVisible('Public IP Address'); - }); - - getVisible(`tr[data-qa-linode="${label}"]`).within(() => { - ui.button - .findByTitle(ip) - .should('be.visible') - .realHover() - .then(() => { - getVisible(`[aria-label="Copy ${ip} to clipboard"]`); - }); - getVisible(`[aria-label="Action menu for Linode ${label}"]`); - }); }); it('checks the action menu items', () => { @@ -296,10 +370,11 @@ describe('linode landing checks', () => { cy.wait('@getLinodes'); // Check 'Group by Tag' button works as expected that can be visible, enabled and clickable - getVisible('[aria-label="Toggle group by tag"]') + cy.get('[aria-label="Toggle group by tag"]') + .should('be.visible') .should('be.enabled') .click(); - getVisible('[data-qa-tag-header="even"]'); + cy.get('[data-qa-tag-header="even"]').should('be.visible'); cy.get('[data-qa-tag-header="even"]').within(() => { mockLinodes.forEach((linode) => { if (linode.tags.includes('even')) { @@ -310,7 +385,7 @@ describe('linode landing checks', () => { }); }); - getVisible('[data-qa-tag-header="odd"]'); + cy.get('[data-qa-tag-header="odd"]').should('be.visible'); cy.get('[data-qa-tag-header="odd"]').within(() => { mockLinodes.forEach((linode) => { if (linode.tags.includes('odd')) { @@ -321,7 +396,7 @@ describe('linode landing checks', () => { }); }); - getVisible('[data-qa-tag-header="nums"]'); + cy.get('[data-qa-tag-header="nums"]').should('be.visible'); cy.get('[data-qa-tag-header="nums"]').within(() => { mockLinodes.forEach((linode) => { cy.findByText(linode.label).should('be.visible'); @@ -329,7 +404,8 @@ describe('linode landing checks', () => { }); // The linode landing table will resume when ungroup the tag. - getVisible('[aria-label="Toggle group by tag"]') + cy.get('[aria-label="Toggle group by tag"]') + .should('be.visible') .should('be.enabled') .click(); cy.get('[data-qa-tag-header="even"]').should('not.exist'); @@ -358,7 +434,10 @@ describe('linode landing checks', () => { cy.wait(['@getLinodes', '@getUserPreferences']); // Check 'Summary View' button works as expected that can be visiable, enabled and clickable - getVisible('[aria-label="Toggle display"]').should('be.enabled').click(); + cy.get('[aria-label="Toggle display"]') + .should('be.visible') + .should('be.enabled') + .click(); cy.wait('@updateUserPreferences'); mockLinodes.forEach((linode) => { @@ -378,7 +457,10 @@ describe('linode landing checks', () => { }); // Toggle the 'List View' button to check the display of table items are back to the original view. - getVisible('[aria-label="Toggle display"]').should('be.enabled').click(); + cy.get('[aria-label="Toggle display"]') + .should('be.visible') + .should('be.enabled') + .click(); cy.findByText('Summary').should('not.exist'); cy.findByText('Public IP Addresses').should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts index aa4601cd7b1..4767b1e6d25 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts @@ -1,6 +1,5 @@ import { Notification } from '@linode/api-v4'; import { notificationFactory } from '@src/factories/notification'; -import { getClick } from 'support/helpers'; import { mockGetNotifications } from 'support/intercepts/events'; const notifications: Notification[] = [ @@ -23,8 +22,8 @@ describe('verify notification types and icons', () => { mockGetNotifications(notifications).as('mockNotifications'); cy.visitWithLogin('/linodes'); cy.wait('@mockNotifications'); - getClick('button[aria-label="Notifications"]'); - getClick('[data-test-id="showMoreButton"'); + cy.get('button[aria-label="Notifications"]').click(); + cy.get('[data-test-id="showMoreButton"').click(); notifications.forEach((notification) => { cy.get(`[data-test-id="${notification.type}"]`).within(() => { if (notification.severity != 'minor') { diff --git a/packages/manager/cypress/support/helpers.ts b/packages/manager/cypress/support/helpers.ts deleted file mode 100644 index 4cbccea82dd..00000000000 --- a/packages/manager/cypress/support/helpers.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* These are shortened methods that will handle finding and clicking or -finding and asserting visible without having to chain. They don't chain off of cy */ -const visible = 'be.visible'; - -/** - * Deprecated. Use `cy.contains(text).should('be.visible')` instead. - * - * @deprecated - */ -export const containsVisible = (text: string) => { - return cy.contains(text).should(visible); -}; - -/** - * Deprecated. Use `cy.contains(text).click()` instead. - * - * @deprecated - */ -export const containsClick = (text: string) => { - return cy.contains(text).click(); -}; - -/** - * Deprecated. Use `cy.findByPlaceholderText(text).click()` instead. - * - * @deprecated - */ -export const containsPlaceholderClick = (text: string) => { - return cy.get(`[placeholder="${text}"]`).click(); -}; - -/** - * Deprecated. Use `cy.get(element).should('be.visible')` instead. - * - * @deprecated - */ -export const getVisible = (element: string) => { - return cy.get(element).should(visible); -}; - -/** - * Deprecated. Use `cy.get(element).click()` instead. - * - * @deprecated - */ -export const getClick = (element: string) => { - return cy.get(element).click(); -}; - -/** - * Deprecated. Use `cy.findByText(text).should('be.visible')` instead. - * - * @deprecated - */ -export const fbtVisible = (text: string) => { - return cy.findByText(text).should(visible); -}; - -/** - * Deprecated. Use `cy.findByText(text).click()` instead. - * - * @deprecated - */ -export const fbtClick = (text: string) => { - return cy.findByText(text).click(); -}; - -/** - * Deprecated. Use `cy.findByLabelText(text).should('be.visible')` instead. - * - * @deprecated - */ -export const fbltVisible = (text: string) => { - return cy.findByLabelText(text).should(visible); -}; - -/** - * Deprecated. Use `cy.findByLabelText(text).click()` instead. - * - * @deprecated - */ -export const fbltClick = (text: string) => { - return cy.findByLabelText(text).click(); -}; From bd631129e2e1a8f098a7cf8edfd72eadba2eba90 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:56:30 -0500 Subject: [PATCH 10/42] change: [M3-9067] - Improve Backups Banner Styles and Refactor Notice Component (#11480) * initial improvements and notice fixes * clean up more * make jsx more readable * fix placement group unit test test id * fix cypress tests * add changesets * tighten spacing * use variant based on UX feedback * remove the `marketing` notice variant * changeset * final fixes and tweaks * clean up a bit more --------- Co-authored-by: Banks Nussman --- .../pr-11480-changed-1736262405005.md | 5 + .../core/account/account-cancellation.spec.ts | 4 +- .../e2e/core/linodes/plan-selection.spec.ts | 2 +- .../DismissibleBanner.styles.ts | 28 --- .../DismissibleBanner/DismissibleBanner.tsx | 58 +++--- .../src/features/Backups/BackupsCTA.styles.ts | 13 -- .../src/features/Backups/BackupsCTA.tsx | 41 +++-- .../features/CloudPulse/Alerts/constants.ts | 2 +- .../GlobalNotifications/EmailBounce.tsx | 4 +- .../VerificationDetailsBanner.tsx | 2 +- .../KubernetesLanding/KubernetesLanding.tsx | 4 +- .../PlacementGroupsDetailPanel.test.tsx | 4 +- .../pr-11480-changed-1736262442937.md | 5 + .../pr-11480-removed-1736802565293.md | 5 + .../ui/src/components/Notice/Notice.styles.ts | 59 +------ .../ui/src/components/Notice/Notice.test.tsx | 13 +- packages/ui/src/components/Notice/Notice.tsx | 166 +++++++----------- 17 files changed, 158 insertions(+), 257 deletions(-) create mode 100644 packages/manager/.changeset/pr-11480-changed-1736262405005.md delete mode 100644 packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts delete mode 100644 packages/manager/src/features/Backups/BackupsCTA.styles.ts create mode 100644 packages/ui/.changeset/pr-11480-changed-1736262442937.md create mode 100644 packages/ui/.changeset/pr-11480-removed-1736802565293.md diff --git a/packages/manager/.changeset/pr-11480-changed-1736262405005.md b/packages/manager/.changeset/pr-11480-changed-1736262405005.md new file mode 100644 index 00000000000..fdd4f2cc995 --- /dev/null +++ b/packages/manager/.changeset/pr-11480-changed-1736262405005.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improved backups banner styles ([#11480](https://github.com/linode/manager/pull/11480)) diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index c984bf30ec1..6211b624c99 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -197,7 +197,7 @@ describe('Account cancellation', () => { // Check both boxes but verify submit remains disabled without email cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); - + ui.button .findByTitle('Close Account') .should('be.visible') @@ -382,7 +382,7 @@ describe('Parent/Child account cancellation', () => { // Check both boxes but verify submit remains disabled without email cy.get('[data-qa-checkbox="deleteAccountServices"]').click(); cy.get('[data-qa-checkbox="deleteAccountUsers"]').click(); - + ui.button .findByTitle('Close Account') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index da168866efc..a23c01564d4 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -131,7 +131,7 @@ const planSelectionTable = 'List of Linode Plans'; const notices = { limitedAvailability: '[data-testid="limited-availability-banner"]', - unavailable: '[data-testid="notice-error"]', + unavailable: '[data-qa-error="true"]', }; authenticate(); diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts deleted file mode 100644 index 6ec0bfda539..00000000000 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Notice, StyledLinkButton } from '@linode/ui'; -import { styled } from '@mui/material/styles'; - -export const StyledNotice = styled(Notice, { label: 'StyledNotice' })( - ({ theme }) => ({ - '&&': { - p: { - lineHeight: '1.25rem', - }, - }, - alignItems: 'center', - background: theme.bg.bgPaper, - borderRadius: 1, - display: 'flex', - flexFlow: 'row nowrap', - justifyContent: 'space-between', - marginBottom: theme.spacing(), - padding: theme.spacing(2), - }) -); - -export const StyledButton = styled(StyledLinkButton, { label: 'StyledButton' })( - ({ theme }) => ({ - color: theme.textColors.tableStatic, - display: 'flex', - marginLeft: 20, - }) -); diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx index 4ee35065ea8..14c5ed1017e 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx @@ -1,12 +1,9 @@ -import { Box } from '@linode/ui'; +import { IconButton, Notice, Stack } from '@linode/ui'; import Close from '@mui/icons-material/Close'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; -import { StyledButton, StyledNotice } from './DismissibleBanner.styles'; - import type { NoticeProps } from '@linode/ui'; import type { DismissibleNotificationOptions } from 'src/hooks/useDismissibleNotifications'; @@ -42,14 +39,7 @@ interface Props extends NoticeProps { * - Call to action: Primary Button or text link allows a user to take action directly from the banner. */ export const DismissibleBanner = (props: Props) => { - const { - actionButton, - children, - className, - options, - preferenceKey, - ...rest - } = props; + const { actionButton, children, options, preferenceKey, ...rest } = props; const { handleDismiss, hasDismissedBanner } = useDismissibleBanner( preferenceKey, @@ -61,32 +51,30 @@ export const DismissibleBanner = (props: Props) => { } const dismissibleButton = ( - - - - - + + + ); return ( - - - {children} - - {actionButton} - {dismissibleButton} - - - + theme.palette.background.paper} + display="flex" + gap={1} + justifyContent="space-between" + {...rest} + > + {children} + + {actionButton} + {dismissibleButton} + + ); }; diff --git a/packages/manager/src/features/Backups/BackupsCTA.styles.ts b/packages/manager/src/features/Backups/BackupsCTA.styles.ts deleted file mode 100644 index d01ba8ff10c..00000000000 --- a/packages/manager/src/features/Backups/BackupsCTA.styles.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Paper } from '@linode/ui'; -import { styled } from '@mui/material/styles'; - -export const StyledPaper = styled(Paper, { - label: 'StyledPaper', -})(({ theme }) => ({ - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - margin: `${theme.spacing(1)} 0 ${theme.spacing(3)} 0`, - padding: theme.spacing(1), - paddingRight: theme.spacing(2), -})); diff --git a/packages/manager/src/features/Backups/BackupsCTA.tsx b/packages/manager/src/features/Backups/BackupsCTA.tsx index dd7d36153e9..5aa84cc5aa8 100644 --- a/packages/manager/src/features/Backups/BackupsCTA.tsx +++ b/packages/manager/src/features/Backups/BackupsCTA.tsx @@ -1,7 +1,8 @@ -import { Box, StyledLinkButton, Typography } from '@linode/ui'; +import { IconButton, Notice, Typography } from '@linode/ui'; import Close from '@mui/icons-material/Close'; -import * as React from 'react'; +import React from 'react'; +import { LinkButton } from 'src/components/LinkButton'; import { useAccountSettings } from 'src/queries/account/settings'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { @@ -11,7 +12,6 @@ import { import { useProfile } from 'src/queries/profile/profile'; import { BackupDrawer } from './BackupDrawer'; -import { StyledPaper } from './BackupsCTA.styles'; export const BackupsCTA = () => { const { data: accountSettings } = useAccountSettings(); @@ -20,7 +20,7 @@ export const BackupsCTA = () => { const { data: isBackupsBannerDismissed } = usePreferences( (preferences) => preferences?.backups_cta_dismissed ); - const { mutateAsync: updatePreferences } = useMutatePreferences(); + const { mutate: updatePreferences } = useMutatePreferences(); const [isBackupsDrawerOpen, setIsBackupsDrawerOpen] = React.useState(false); @@ -44,26 +44,31 @@ export const BackupsCTA = () => { } return ( - - - setIsBackupsDrawerOpen(true)}> + theme.palette.background.paper} + display="flex" + flexDirection="row" + justifyContent="space-between" + spacingBottom={8} + variant="info" + > + + setIsBackupsDrawerOpen(true)}> Enable Linode Backups - {' '} + {' '} to protect your data and recover quickly in an emergency. - - - - - + + + setIsBackupsDrawerOpen(false)} open={isBackupsDrawerOpen} /> - + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index f6397392284..f4fe1baefd3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -116,7 +116,7 @@ export const PollingIntervalOptions = { { label: '10 min', value: 600 }, ], }; - + export const severityMap: Record = { 0: 'Severe', 1: 'Medium', diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index cfe556231a0..11e45705837 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -128,8 +128,8 @@ const EmailBounceNotification = React.memo((props: Props) => { } return ( - - + + {text} diff --git a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx index 7e62dedbd18..f763c7142aa 100644 --- a/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx +++ b/packages/manager/src/features/GlobalNotifications/VerificationDetailsBanner.tsx @@ -24,7 +24,7 @@ export const VerificationDetailsBanner = ({ } return ( - + { {isDiskEncryptionFeatureEnabled && ( - + {DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx index b788bcd91d8..fdab373c4b7 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.test.tsx @@ -112,7 +112,9 @@ describe('PlacementGroupsDetailPanel', () => { ); expect(getByRole('combobox')).toBeDisabled(); - expect(getByTestId('notice-warning')).toHaveTextContent( + expect( + getByTestId('placement-groups-no-capability-notice') + ).toHaveTextContent( 'Currently, only specific regions support placement groups.' ); expect( diff --git a/packages/ui/.changeset/pr-11480-changed-1736262442937.md b/packages/ui/.changeset/pr-11480-changed-1736262442937.md new file mode 100644 index 00000000000..a366ba78ad5 --- /dev/null +++ b/packages/ui/.changeset/pr-11480-changed-1736262442937.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Changed +--- + +Refactor and clean up `Notice` ([#11480](https://github.com/linode/manager/pull/11480)) diff --git a/packages/ui/.changeset/pr-11480-removed-1736802565293.md b/packages/ui/.changeset/pr-11480-removed-1736802565293.md new file mode 100644 index 00000000000..c2d0ddfef3b --- /dev/null +++ b/packages/ui/.changeset/pr-11480-removed-1736802565293.md @@ -0,0 +1,5 @@ +--- +"@linode/ui": Removed +--- + +`marketing` variant on `Notice` component ([#11480](https://github.com/linode/manager/pull/11480)) diff --git a/packages/ui/src/components/Notice/Notice.styles.ts b/packages/ui/src/components/Notice/Notice.styles.ts index 1e6b45ca9d3..088f1256728 100644 --- a/packages/ui/src/components/Notice/Notice.styles.ts +++ b/packages/ui/src/components/Notice/Notice.styles.ts @@ -1,18 +1,7 @@ import { makeStyles } from 'tss-react/mui'; -import type { Theme } from '@mui/material/styles'; - -export const useStyles = makeStyles< - void, - 'error' | 'icon' | 'important' | 'noticeText' ->()((theme: Theme, _params, classes) => ({ +export const useStyles = makeStyles()((theme) => ({ error: { - [`&.${classes.important}`]: { - borderLeftWidth: 32, - }, - borderLeft: `5px solid ${theme.palette.error.dark}`, - }, - errorList: { borderLeft: `5px solid ${theme.palette.error.dark}`, }, icon: { @@ -21,30 +10,14 @@ export const useStyles = makeStyles< position: 'absolute', }, important: { - '&.MuiGrid2-root': { - padding: theme.spacing(1), - paddingRight: 18, - }, - [`& .${classes.noticeText}`]: { - fontFamily: theme.font.normal, - }, - backgroundColor: theme.bg.bgPaper, + backgroundColor: theme.palette.background.paper, + borderLeftWidth: 32, + fontFamily: theme.font.normal, + padding: theme.spacing(1), }, info: { - [`&.${classes.important}`]: { - borderLeftWidth: 32, - }, - borderLeft: `5px solid ${theme.palette.info.dark}`, - }, - infoList: { borderLeft: `5px solid ${theme.palette.info.dark}`, }, - inner: { - width: '100%', - }, - marketing: { - borderLeft: `5px solid ${theme.color.green}`, - }, noticeText: { fontFamily: theme.font.bold, fontSize: '1rem', @@ -54,37 +27,21 @@ export const useStyles = makeStyles< '& + .notice': { marginTop: `${theme.spacing()} !important`, }, - [`& .${classes.error}`]: { - borderLeftColor: theme.color.red, - }, alignItems: 'center', borderRadius: 1, display: 'flex', fontSize: '1rem', maxWidth: '100%', - padding: '4px 16px', - paddingRight: 18, + padding: `${theme.spacing(0.5)} ${theme.spacing(2)}`, position: 'relative', }, success: { - [`&.${classes.important}`]: { - borderLeftWidth: 32, - }, - borderLeft: `5px solid ${theme.palette.success.dark}`, - }, - successList: { borderLeft: `5px solid ${theme.palette.success.dark}`, }, warning: { - [`& .${classes.icon}`]: { - color: theme.tokens.color.Neutrals[80], - }, - [`&.${classes.important}`]: { - borderLeftWidth: 32, - }, borderLeft: `5px solid ${theme.palette.warning.dark}`, }, - warningList: { - borderLeft: `5px solid ${theme.palette.warning.dark}`, + warningIcon: { + color: theme.tokens.color.Neutrals[80], }, })); diff --git a/packages/ui/src/components/Notice/Notice.test.tsx b/packages/ui/src/components/Notice/Notice.test.tsx index 86a6d5b651a..2e8675d54fa 100644 --- a/packages/ui/src/components/Notice/Notice.test.tsx +++ b/packages/ui/src/components/Notice/Notice.test.tsx @@ -46,13 +46,18 @@ describe('Notice Component', () => { expect(container.firstChild).toHaveClass('custom-class'); }); - it('applies dataTestId props', () => { + it('applies a default test-id based on the variant', () => { + const { getByTestId } = renderWithTheme(); + + expect(getByTestId('notice-success')).toBeInTheDocument(); + }); + + it('applies the dataTestId prop', () => { const { getByTestId } = renderWithTheme( - + ); - expect(getByTestId('notice-success')).toBeInTheDocument(); - expect(getByTestId('test-id')).toBeInTheDocument(); + expect(getByTestId('my-custom-test-id')).toBeInTheDocument(); }); it('applies variant prop', () => { diff --git a/packages/ui/src/components/Notice/Notice.tsx b/packages/ui/src/components/Notice/Notice.tsx index 3108325401a..9c8591bcbc4 100644 --- a/packages/ui/src/components/Notice/Notice.tsx +++ b/packages/ui/src/components/Notice/Notice.tsx @@ -1,23 +1,20 @@ -import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; -import * as React from 'react'; +import React from 'react'; -import { CheckIcon, AlertIcon as Error, WarningIcon } from '../../assets/icons'; -import { omittedProps } from '../../utilities'; +import { + CheckIcon, + AlertIcon as ErrorIcon, + WarningIcon, +} from '../../assets/icons'; +import { Box } from '../Box'; import { Typography } from '../Typography'; import { useStyles } from './Notice.styles'; +import type { BoxProps } from '../Box'; import type { TypographyProps } from '../Typography'; -import type { Grid2Props } from '@mui/material/Unstable_Grid2'; -export type NoticeVariant = - | 'error' - | 'info' - | 'marketing' - | 'success' - | 'warning'; +export type NoticeVariant = 'error' | 'info' | 'success' | 'warning'; -export interface NoticeProps extends Grid2Props { +export interface NoticeProps extends BoxProps { /** * If true, the error will be treated as "static" and will not be included in the error group. * This will essentially disable the scroll to error behavior. @@ -49,7 +46,7 @@ export interface NoticeProps extends Grid2Props { */ spacingTop?: 0 | 4 | 8 | 12 | 16 | 24 | 32; /** - * The text to display in the error. If this is not provided, props.children will be used. + * The text to display in the notice. If this is not provided, props.children will be used. */ text?: string; /** @@ -57,7 +54,7 @@ export interface NoticeProps extends Grid2Props { */ typeProps?: TypographyProps; /** - * The variant of the error. This will determine the color treatment of the error. + * The variant of the notice. This will determine the color treatment of the error. */ variant?: NoticeVariant; } @@ -72,9 +69,9 @@ export interface NoticeProps extends Grid2Props { ## Types of Notices: -- Success/Marketing (green line) +- Success (green line) - Info (blue line) -- Error/critical (red line) +- Error (red line) - Warning (yellow line) */ export const Notice = (props: NoticeProps) => { @@ -85,7 +82,6 @@ export const Notice = (props: NoticeProps) => { dataTestId, errorGroup, important, - onClick, spacingBottom, spacingLeft, spacingTop, @@ -93,44 +89,18 @@ export const Notice = (props: NoticeProps) => { text, typeProps, variant, + ...rest } = props; - const { classes, cx } = useStyles(); - const innerText = text ? ( - - {text} - - ) : null; + const { classes, cx } = useStyles(); const variantMap = { error: variant === 'error', info: variant === 'info', - marketing: variant === 'marketing', success: variant === 'success', warning: variant === 'warning', }; - /** - * There are some cases where the message - * can be either a string or JSX. In those - * cases we should use props.children, but - * we want to make sure the string is wrapped - * in Typography and formatted as it would be - * if it were passed as props.text. - */ - const _children = - typeof children === 'string' ? ( - - {children} - - ) : ( - children - ); - const errorScrollClassName = bypassValidation ? '' : errorGroup @@ -147,59 +117,59 @@ export const Notice = (props: NoticeProps) => { }; return ( - ({ + marginBottom: + spacingBottom !== undefined + ? `${spacingBottom}px` + : theme.spacing(3), + marginLeft: spacingLeft !== undefined ? `${spacingLeft}px` : 0, + marginTop: spacingTop !== undefined ? `${spacingTop}px` : 0, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} role="alert" + {...dataAttributes} + {...rest} > - {important && - ((variantMap.success && ( - - )) || - ((variantMap.warning || variantMap.info) && ( - - )) || - (variantMap.error && ( - - )))} -
    - {innerText || _children} -
    -
    + {important && variantMap.error && } + {important && variantMap.info && } + {important && variantMap.success && ( + + )} + {important && variantMap.warning && ( + + )} + {text || typeof children === 'string' ? ( + + {text ?? children} + + ) : ( + children + )} +
    ); }; - -export const StyledNoticeGrid = styled(Grid, { - label: 'StyledNoticeGrid', - shouldForwardProp: omittedProps([ - 'spacingBottom', - 'spacingLeft', - 'spacingTop', - ]), -})(({ theme, ...props }) => ({ - marginBottom: props.spacingBottom ?? theme.spacing(3), - marginLeft: props.spacingLeft ?? 0, - marginTop: props.spacingTop ?? 0, -})); From aefd87099788df1dbc18564239e672605b96a419 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Tue, 14 Jan 2025 05:59:49 +0530 Subject: [PATCH 11/42] upcoming: [DI-22132] - Added criteria section in alert details page for ACLP Alerting (#11477) * upcoming: [DI-22596] - Criteria changes * upcoming: [DI-22596] - Criteria changes * upcoming: [DI-22596] - Criteria changes * upcoming: [DI-22596] - Alert detail chips * upcoming: [DI-22596] - CSS changes * upcoming: [DI-22596] - Code refactoring * upcoming: [DI-22596] - Add types * upcoming: [DI-22132] - Code refactoring * upcoming: [DI-22132] - Add changeset * upcoming: [DI-22132] - Add factories and constants * upcoming: [DI-22132] - Use factories in mock * upcoming: [DI-22132] - Refactor alert criteria component * upcoming: [DI-22132] - Code refactoring, util update and constants update * upcoming: [DI-22132] - Code refactoring * upcoming: [DI-22132] - UT updates and code clean up * upcoming: [DI-22132] - Code updates * upcoming: [DI-22132] - Comment update and label update * upcoming: [DI-22132] - Code refactoring and updates * upcoming: [DI-22132] - Reusable typography * upcoming: [DI-22132] - Use common typography * upcoming: [DI-22132] - Rename common typography * upcoming: [DI-22132] - Add logical comments * upcoming: [DI-22132] - Add spacing constant * upcoming: [DI-22132] - Code refactoring * upcoming: [DI-22132] - Code refactoring * upcoming: [DI-22132] - CSS fixes * upcoming: [DI-22132] - Remove pick random * upcoming: [DI-22132] - Code merge error fixes * upcoming: [DI-22132] - Merge imports into one * upcoming: [DI-22132] - Color changes for PR * upcoming: [DI-22132] - ES lint issue fix * upcoming: [DI-22132] - Height changes to px value * upcoming: [DI-22132] - Constants and text update --------- Co-authored-by: vmangalr --- ...r-11477-upcoming-features-1736156265225.md | 5 + .../src/factories/cloudpulse/alerts.ts | 31 ++++++- .../Alerts/AlertsDetail/AlertDetail.test.tsx | 1 + .../Alerts/AlertsDetail/AlertDetail.tsx | 45 ++++++++- .../AlertsDetail/AlertDetailCriteria.test.tsx | 64 +++++++++++++ .../AlertsDetail/AlertDetailCriteria.tsx | 81 ++++++++++++++++ .../Alerts/AlertsDetail/AlertDetailRow.tsx | 18 +--- .../AlertsDetail/DisplayAlertDetailChips.tsx | 93 +++++++++++++++++++ .../RenderAlertsMetricsAndDimensions.tsx | 86 +++++++++++++++++ .../CloudPulse/Alerts/Utils/utils.test.ts | 10 +- .../features/CloudPulse/Alerts/Utils/utils.ts | 58 ++++++++++++ .../features/CloudPulse/Alerts/constants.ts | 25 ++++- packages/manager/src/mocks/serverHandlers.ts | 10 ++ 13 files changed, 504 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-11477-upcoming-features-1736156265225.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx diff --git a/packages/manager/.changeset/pr-11477-upcoming-features-1736156265225.md b/packages/manager/.changeset/pr-11477-upcoming-features-1736156265225.md new file mode 100644 index 00000000000..4a8a78e1105 --- /dev/null +++ b/packages/manager/.changeset/pr-11477-upcoming-features-1736156265225.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Alert Details Criteria section in Cloud Pulse Alert Details page ([#11477](https://github.com/linode/manager/pull/11477)) diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 5a47ea798ae..58669164b9e 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -1,7 +1,32 @@ import Factory from 'src/factories/factoryProxy'; +import type { + AlertDefinitionDimensionFilter, + AlertDefinitionMetricCriteria, +} from '@linode/api-v4'; import type { Alert } from '@linode/api-v4'; +export const alertDimensionsFactory = Factory.Sync.makeFactory( + { + dimension_label: 'operating_system', + label: 'Operating System', + operator: 'eq', + value: 'Linux', + } +); + +export const alertRulesFactory = Factory.Sync.makeFactory( + { + aggregation_type: 'avg', + dimension_filters: alertDimensionsFactory.buildList(1), + label: 'CPU Usage', + metric: 'cpu_usage', + operator: 'eq', + threshold: 60, + unit: 'Bytes', + } +); + export const alertFactory = Factory.Sync.makeFactory({ channels: [], created: new Date().toISOString(), @@ -20,9 +45,9 @@ export const alertFactory = Factory.Sync.makeFactory({ tags: ['tag1', 'tag2'], trigger_conditions: { criteria_condition: 'ALL', - evaluation_period_seconds: 0, - polling_interval_seconds: 0, - trigger_occurrences: 0, + evaluation_period_seconds: 240, + polling_interval_seconds: 120, + trigger_occurrences: 3, }, type: 'user', updated: new Date().toISOString(), diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx index e053f94493d..04641bb7d27 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx @@ -81,6 +81,7 @@ describe('AlertDetail component tests', () => { const { getByText } = renderWithTheme(); // validate overview is present with its couple of properties (values will be validated in its own components test) expect(getByText('Overview')).toBeInTheDocument(); + expect(getByText('Criteria')).toBeInTheDocument(); // validate if criteria is present expect(getByText('Name:')).toBeInTheDocument(); expect(getByText('Description:')).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index cd6c3a3aaa7..b1212d699b4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -1,4 +1,4 @@ -import { Box, CircleProgress } from '@linode/ui'; +import { Box, Chip, CircleProgress, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material'; import React from 'react'; import { useParams } from 'react-router-dom'; @@ -10,6 +10,7 @@ import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; import { getAlertBoxStyles } from '../Utils/utils'; +import { AlertDetailCriteria } from './AlertDetailCriteria'; import { AlertDetailOverview } from './AlertDetailOverview'; interface RouteParams { @@ -48,12 +49,14 @@ export const AlertDetail = () => { }, [alertId, serviceType]); const theme = useTheme(); + const nonSuccessBoxHeight = '600px'; + const sectionMaxHeight = '785px'; if (isFetching) { return ( <> - + @@ -64,7 +67,7 @@ export const AlertDetail = () => { return ( <> - + @@ -75,7 +78,7 @@ export const AlertDetail = () => { return ( <> - + { + + + @@ -114,3 +127,25 @@ export const StyledPlaceholder = styled(Placeholder, { maxHeight: theme.spacing(10), }, })); + +export const StyledAlertChip = styled(Chip, { + label: 'StyledAlertChip', + shouldForwardProp: (prop) => prop !== 'borderRadius', +})<{ + borderRadius?: string; +}>(({ borderRadius, theme }) => ({ + '& .MuiChip-label': { + color: theme.tokens.content.Text.Primary.Default, + marginRight: theme.spacing(1), + }, + backgroundColor: theme.tokens.background.Normal, + borderRadius: borderRadius || 0, + height: theme.spacing(3), +})); + +export const StyledAlertTypography = styled(Typography, { + label: 'StyledAlertTypography', +})(({ theme }) => ({ + color: theme.tokens.content.Text.Primary.Default, + fontSize: theme.typography.body1.fontSize, +})); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx new file mode 100644 index 00000000000..49ea7b6e017 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import { + alertDimensionsFactory, + alertFactory, + alertRulesFactory, +} from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { metricOperatorTypeMap } from '../constants'; +import { convertSecondsToMinutes } from '../Utils/utils'; +import { AlertDetailCriteria } from './AlertDetailCriteria'; + +describe('AlertDetailCriteria component tests', () => { + it('should render the alert detail criteria successfully on correct inputs', () => { + const alertDetails = alertFactory.build({ + rule_criteria: { + rules: alertRulesFactory.buildList(2, { + aggregation_type: 'avg', + dimension_filters: alertDimensionsFactory.buildList(2), + label: 'CPU Usage', + metric: 'cpu_usage', + operator: 'gt', + unit: 'bytes', + }), + }, + }); + const { getAllByText, getByText } = renderWithTheme( + + ); + const { rules } = alertDetails.rule_criteria; + expect(getAllByText('Metric Threshold:').length).toBe(rules.length); + expect(getAllByText('Dimension Filter:').length).toBe(rules.length); + expect(getByText('Criteria')).toBeInTheDocument(); + expect(getAllByText('Average').length).toBe(2); + expect(getAllByText('CPU Usage').length).toBe(2); + expect(getAllByText('bytes').length).toBe(2); + expect(getAllByText(metricOperatorTypeMap['gt']).length).toBe(2); + const { + evaluation_period_seconds, + polling_interval_seconds, + } = alertDetails.trigger_conditions; + expect( + getByText(convertSecondsToMinutes(polling_interval_seconds)) + ).toBeInTheDocument(); + expect( + getByText(convertSecondsToMinutes(evaluation_period_seconds)) + ).toBeInTheDocument(); + }); + + it('should render the alert detail criteria even if rules are empty', () => { + const alert = alertFactory.build({ + rule_criteria: { + rules: [], + }, + }); + const { getByText, queryByText } = renderWithTheme( + + ); + expect(getByText('Criteria')).toBeInTheDocument(); // empty criteria should be there + expect(queryByText('Metric Threshold:')).not.toBeInTheDocument(); + expect(queryByText('Dimension Filter:')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx new file mode 100644 index 00000000000..73af8b4e528 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx @@ -0,0 +1,81 @@ +import { Typography } from '@linode/ui'; +import { Grid, useTheme } from '@mui/material'; +import React from 'react'; + +import { convertSecondsToMinutes } from '../Utils/utils'; +import { StyledAlertChip, StyledAlertTypography } from './AlertDetail'; +import { DisplayAlertDetailChips } from './DisplayAlertDetailChips'; +import { RenderAlertMetricsAndDimensions } from './RenderAlertsMetricsAndDimensions'; + +import type { Alert } from '@linode/api-v4'; + +interface CriteriaProps { + /** + * The alert detail object for which the criteria needs to be displayed + */ + alertDetails: Alert; +} + +export const AlertDetailCriteria = React.memo((props: CriteriaProps) => { + const { alertDetails } = props; + const { + evaluation_period_seconds: evaluationPeriod, + polling_interval_seconds: pollingIntervalSeconds, + trigger_occurrences: triggerOccurrences, + } = alertDetails.trigger_conditions; + const { rule_criteria: ruleCriteria = { rules: [] } } = alertDetails; + const theme = useTheme(); + + // Memoized trigger criteria rendering + const renderTriggerCriteria = React.useMemo( + () => ( + <> + + + Trigger Alert When: + + + + + + criteria are met for + + + + consecutive occurrences. + + + + ), + [theme, triggerOccurrences] + ); + return ( + <> + + Criteria + + + + + + {renderTriggerCriteria} {/** Render the trigger criteria */} + + + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx index 6e7683fdb8d..02d6c63af0b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailRow.tsx @@ -1,9 +1,10 @@ -import { Typography } from '@linode/ui'; import { Grid, useTheme } from '@mui/material'; import React from 'react'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { StyledAlertTypography } from './AlertDetail'; + import type { Status } from 'src/components/StatusIcon/StatusIcon'; interface AlertDetailRowProps { @@ -46,13 +47,9 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { return ( - + {label}: - + {status && ( @@ -63,12 +60,7 @@ export const AlertDetailRow = React.memo((props: AlertDetailRowProps) => { status={status} /> )} - - {value} - + {value} ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx new file mode 100644 index 00000000000..c3fff1256ed --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/DisplayAlertDetailChips.tsx @@ -0,0 +1,93 @@ +import { Grid, useTheme } from '@mui/material'; +import React from 'react'; + +import { getAlertChipBorderRadius } from '../Utils/utils'; +import { StyledAlertChip, StyledAlertTypography } from './AlertDetail'; + +export interface AlertDimensionsProp { + /** + * The label or title of the chips + */ + label: string; + /** + * Number of grid columns for the label on medium to larger screens. + * Defaults to 4. This controls the width of the label in the grid layout. + */ + labelGridColumns?: number; + /** + * Determines whether chips should be displayed individually + * or merged into a single row + */ + mergeChips?: boolean; + /** + * Number of grid columns for the value on medium to larger screens. + * Defaults to 8. This controls the width of the value in the grid layout. + */ + valueGridColumns?: number; + /** + * The list of chip labels to be displayed. + * Can be a flat array of strings or a nested array for grouped chips. + * Example: ['chip1', 'chip2'] or [['group1-chip1', 'group1-chip2'], ['group2-chip1']] + */ + values: Array | Array; +} + +export const DisplayAlertDetailChips = React.memo( + (props: AlertDimensionsProp) => { + const { + label, + labelGridColumns = 4, + mergeChips, + valueGridColumns = 8, + values: values, + } = props; + + const chipValues: string[][] = Array.isArray(values) + ? values.every(Array.isArray) + ? values + : [values] + : []; + const theme = useTheme(); + return ( + + {chipValues.map((value, index) => ( + + + {index === 0 && ( + + {label}: + + )} + + + + {value.map((label, index) => ( + 0 ? -1 : 0} + > + + + ))} + + + + ))} + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx new file mode 100644 index 00000000000..0aac5132726 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx @@ -0,0 +1,86 @@ +import { Divider } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; + +import NullComponent from 'src/components/NullComponent'; + +import { + aggregationTypeMap, + dimensionOperatorTypeMap, + metricOperatorTypeMap, +} from '../constants'; +import { DisplayAlertDetailChips } from './DisplayAlertDetailChips'; + +import type { AlertDefinitionMetricCriteria } from '@linode/api-v4'; + +interface AlertMetricAndDimensionsProp { + /* + * The rule criteria associated with the alert for which the dimension filters are needed to be displayed + */ + ruleCriteria: { + rules: AlertDefinitionMetricCriteria[]; + }; +} + +export const RenderAlertMetricsAndDimensions = React.memo( + (props: AlertMetricAndDimensionsProp) => { + const { ruleCriteria } = props; + + if (!ruleCriteria.rules?.length) { + return ; + } + + return ruleCriteria.rules.map( + ( + { + aggregation_type: aggregationType, + dimension_filters: dimensionFilters, + label, + operator, + threshold, + unit, + }, + index + ) => ( + + + + + + {dimensionFilters && dimensionFilters.length > 0 && ( + + [ + dimensionLabel, + dimensionOperatorTypeMap[dimensionOperator], + value, + ] + )} + label="Dimension Filter" + mergeChips + /> + + )} + + + + + ) + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index 549b35e9a98..7fcdd3f5873 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -1,6 +1,6 @@ import { serviceTypesFactory } from 'src/factories'; -import { getServiceTypeLabel } from './utils'; +import { convertSecondsToMinutes, getServiceTypeLabel } from './utils'; it('test getServiceTypeLabel method', () => { const services = serviceTypesFactory.buildList(3); @@ -13,3 +13,11 @@ it('test getServiceTypeLabel method', () => { expect(getServiceTypeLabel('test', { data: services })).toBe('test'); expect(getServiceTypeLabel('', { data: services })).toBe(''); }); +it('test convertSecondsToMinutes method', () => { + expect(convertSecondsToMinutes(0)).toBe('0 minutes'); + expect(convertSecondsToMinutes(60)).toBe('1 minute'); + expect(convertSecondsToMinutes(120)).toBe('2 minutes'); + expect(convertSecondsToMinutes(65)).toBe('1 minute and 5 seconds'); + expect(convertSecondsToMinutes(1)).toBe('1 second'); + expect(convertSecondsToMinutes(59)).toBe('59 seconds'); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 4e1ffe31d26..00b734f4447 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -1,6 +1,26 @@ import type { ServiceTypesList } from '@linode/api-v4'; import type { Theme } from '@mui/material'; +interface AlertChipBorderProps { + /** + * The radius needed for the border + */ + borderRadiusPxValue: string; + /** + * The index of the chip + */ + index: number; + /** + * The total length of the chips to be build + */ + length: number; + + /** + * Indicates Whether to merge the chips into single or keep it individually + */ + mergeChips: boolean | undefined; +} + /** * @param serviceType Service type for which the label needs to be displayed * @param serviceTypeList List of available service types in Cloud Pulse @@ -29,3 +49,41 @@ export const getAlertBoxStyles = (theme: Theme) => ({ backgroundColor: theme.tokens.background.Neutral, padding: theme.spacing(3), }); +/** + * Converts seconds into a human-readable minutes and seconds format. + * @param seconds The seconds that need to be converted into minutes. + * @returns A string representing the time in minutes and seconds. + */ +export const convertSecondsToMinutes = (seconds: number): string => { + if (seconds <= 0) { + return '0 minutes'; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + const minuteString = + minutes > 0 ? `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}` : ''; + const secondString = + remainingSeconds > 0 + ? `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}` + : ''; + return [minuteString, secondString].filter(Boolean).join(' and '); +}; +/** + * @param props The props/parameters needed to determine the alert chip's border + * @returns The border radius to be applied on chips based on the parameters + */ +export const getAlertChipBorderRadius = ( + props: AlertChipBorderProps +): string => { + const { borderRadiusPxValue, index, length, mergeChips } = props; + if (!mergeChips || length === 1) { + return borderRadiusPxValue; + } + if (index === 0) { + return `${borderRadiusPxValue} 0 0 ${borderRadiusPxValue}`; + } + if (index === length - 1) { + return `0 ${borderRadiusPxValue} ${borderRadiusPxValue} 0`; + } + return '0'; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index f4fe1baefd3..d7bc658875c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,7 +1,7 @@ import type { AlertSeverityType, - DimensionFilterOperatorType, AlertStatusType, + DimensionFilterOperatorType, MetricAggregationType, MetricOperatorType, } from '@linode/api-v4'; @@ -128,3 +128,26 @@ export const alertStatusToIconStatusMap: Record = { disabled: 'inactive', enabled: 'active', }; +export const metricOperatorTypeMap: Record = { + eq: '=', + gt: '>', + gte: '>=', + lt: '<', + lte: '<=', +}; +export const aggregationTypeMap: Record = { + avg: 'Average', + count: 'Count', + max: 'Maximum', + min: 'Minimum', + sum: 'Sum', +}; +export const dimensionOperatorTypeMap: Record< + DimensionFilterOperatorType, + string +> = { + endswith: 'ends with', + eq: 'equals', + neq: 'not equals', + startswith: 'starts with', +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 3f48d7490c8..44634a9e948 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -19,7 +19,9 @@ import { accountFactory, accountMaintenanceFactory, accountTransferFactory, + alertDimensionsFactory, alertFactory, + alertRulesFactory, appTokenFactory, betaFactory, contactFactory, @@ -2454,6 +2456,14 @@ export const handlers = [ return HttpResponse.json( alertFactory.build({ id: Number(params.id), + rule_criteria: { + rules: [ + ...alertRulesFactory.buildList(2, { + dimension_filters: alertDimensionsFactory.buildList(2), + }), + ...alertRulesFactory.buildList(1, { dimension_filters: [] }), + ], + }, service_type: params.serviceType === 'linode' ? 'linode' : 'dbaas', }) ); From 96690a279508da3ce3ebb74730e1660fede4eb50 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 14 Jan 2025 09:50:39 -0500 Subject: [PATCH 12/42] change: Update understanding bucket rate limits for OBJ Gen2 (#11513) * Update understanding bucket rate limits link * Added changeset: Tech doc link for Bucket Rate Limits have changed * Update link --------- Co-authored-by: Jaalah Ramos --- .../manager/.changeset/pr-11513-changed-1736782843874.md | 5 +++++ .../ObjectStorage/BucketLanding/BucketRateLimitTable.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-11513-changed-1736782843874.md diff --git a/packages/manager/.changeset/pr-11513-changed-1736782843874.md b/packages/manager/.changeset/pr-11513-changed-1736782843874.md new file mode 100644 index 00000000000..86cd58ef5a9 --- /dev/null +++ b/packages/manager/.changeset/pr-11513-changed-1736782843874.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Tech doc link for Bucket Rate Limits have changed ([#11513](https://github.com/linode/manager/pull/11513)) diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx index 499f897df60..594d77bb535 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx @@ -83,7 +83,7 @@ export const BucketRateLimitTable = ({ 'This endpoint type supports up to 750 Requests Per Second (RPS). ' )} Understand{' '} - + bucket rate limits . From 57457dcfbb900843304d63cfc9c5832c017b54ec Mon Sep 17 00:00:00 2001 From: Ankita Date: Tue, 14 Jan 2025 21:29:06 +0530 Subject: [PATCH 13/42] upcoming: [DI-22714] - Metrics and JWE Token api request update in CloudPulse (#11506) * upcoming: [DI-22714] - metrics call request update * upcoming: [DI-22714] - update factory * upcoming: [DI-22714] - jwe token call payload update * upcoming: [DI-22714] - small enhancement * upcoming: [DI-22714] - Add changeset --- packages/api-v4/src/cloudpulse/types.ts | 6 +++--- .../pr-11506-upcoming-features-1736518383049.md | 5 +++++ packages/manager/src/factories/dashboards.ts | 2 +- .../CloudPulse/Dashboard/CloudPulseDashboard.tsx | 2 +- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 12 ++++++------ .../CloudPulse/Widget/CloudPulseWidget.test.tsx | 2 +- .../CloudPulse/Widget/CloudPulseWidget.tsx | 14 +++++++------- .../CloudPulse/Widget/CloudPulseWidgetRenderer.tsx | 2 +- packages/manager/src/queries/cloudpulse/metrics.ts | 4 ++-- packages/manager/src/queries/cloudpulse/queries.ts | 2 +- 10 files changed, 28 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-11506-upcoming-features-1736518383049.md diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 2e4e6c4658a..7fb5db6ce6e 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -55,7 +55,7 @@ export interface Widgets { filters: Filters[]; serviceType: string; service_type: string; - resource_id: string[]; + entity_ids: string[]; time_granularity: TimeGranularity; time_duration: TimeDuration; unit: string; @@ -106,7 +106,7 @@ export interface Dimension { } export interface JWETokenPayLoad { - resource_ids: number[]; + entity_ids: number[]; } export interface JWEToken { @@ -120,7 +120,7 @@ export interface CloudPulseMetricsRequest { group_by: string; relative_time_duration: TimeDuration; time_granularity: TimeGranularity | undefined; - resource_ids: number[]; + entity_ids: number[]; } export interface CloudPulseMetricsResponse { diff --git a/packages/manager/.changeset/pr-11506-upcoming-features-1736518383049.md b/packages/manager/.changeset/pr-11506-upcoming-features-1736518383049.md new file mode 100644 index 00000000000..4e6c1c43f5c --- /dev/null +++ b/packages/manager/.changeset/pr-11506-upcoming-features-1736518383049.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Metrics api request and JWE Token api request in CloudPulse ([#11506](https://github.com/linode/manager/pull/11506)) diff --git a/packages/manager/src/factories/dashboards.ts b/packages/manager/src/factories/dashboards.ts index 275b05f0506..c02bf807d92 100644 --- a/packages/manager/src/factories/dashboards.ts +++ b/packages/manager/src/factories/dashboards.ts @@ -30,13 +30,13 @@ export const widgetFactory = Factory.Sync.makeFactory({ aggregate_function: 'avg', chart_type: Factory.each((i) => chart_type[i % chart_type.length]), color: Factory.each((i) => color[i % color.length]), + entity_ids: Factory.each((i) => [`resource-${i}`]), filters: [], group_by: 'region', label: Factory.each((i) => `widget_label_${i}`), metric: Factory.each((i) => `widget_metric_${i}`), namespace_id: Factory.each((i) => i % 10), region_id: Factory.each((i) => i % 5), - resource_id: Factory.each((i) => [`resource-${i}`]), service_type: 'default', serviceType: 'default', size: 12, diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 5cb487cc9b8..4aa133e65cd 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -72,7 +72,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { const getJweTokenPayload = (): JWETokenPayLoad => { return { - resource_ids: resources?.map((resource) => Number(resource)) ?? [], + entity_ids: resources?.map((resource) => Number(resource)) ?? [], }; }; diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 6057a3dc770..f565b7ae272 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -103,9 +103,9 @@ interface MetricRequestProps { duration: TimeDuration; /** - * resource ids selected by user + * entity ids selected by user */ - resourceIds: string[]; + entityIds: string[]; /** * list of CloudPulse resources available @@ -286,16 +286,16 @@ export const generateMaxUnit = ( export const getCloudPulseMetricRequest = ( props: MetricRequestProps ): CloudPulseMetricsRequest => { - const { duration, resourceIds, resources, widget } = props; + const { duration, entityIds, resources, widget } = props; return { aggregate_function: widget.aggregate_function, + entity_ids: resources + ? entityIds.map((id) => parseInt(id, 10)) + : widget.entity_ids.map((id) => parseInt(id, 10)), filters: undefined, group_by: widget.group_by, metric: widget.metric, relative_time_duration: duration ?? widget.time_duration, - resource_ids: resources - ? resourceIds.map((obj) => parseInt(obj, 10)) - : widget.resource_id.map((obj) => parseInt(obj, 10)), time_granularity: widget.time_granularity.unit === 'Auto' ? undefined diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx index e5d3a1fae74..9524fd02f2a 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx @@ -26,8 +26,8 @@ const props: CloudPulseWidgetProperties = { unit: 'percent', }, duration: { unit: 'min', value: 30 }, + entityIds: ['1', '2'], isJweTokenFetching: false, - resourceIds: ['1', '2'], resources: [ { id: '1', diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index e8d6142276b..e71e19e68f1 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -63,6 +63,11 @@ export interface CloudPulseWidgetProperties { */ duration: TimeDuration; + /** + * entity ids selected by user to show metrics for + */ + entityIds: string[]; + /** * Any error to be shown in this widget */ @@ -73,11 +78,6 @@ export interface CloudPulseWidgetProperties { */ isJweTokenFetching: boolean; - /** - * resources ids selected by user to show metrics for - */ - resourceIds: string[]; - /** * List of resources available of selected service type */ @@ -141,8 +141,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { authToken, availableMetrics, duration, + entityIds, isJweTokenFetching, - resourceIds, resources, savePref, serviceType, @@ -230,7 +230,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { { ...getCloudPulseMetricRequest({ duration, - resourceIds, + entityIds, resources, widget, }), diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index df9c19f4124..167d6aa80b8 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -75,9 +75,9 @@ export const RenderWidgets = React.memo( authToken: '', availableMetrics: undefined, duration, + entityIds: resources, errorLabel: 'Error occurred while loading data.', isJweTokenFetching: false, - resourceIds: resources, resources: [], serviceType: dashboard?.service_type ?? '', timeStamp: manualRefreshTimeStamp, diff --git a/packages/manager/src/queries/cloudpulse/metrics.ts b/packages/manager/src/queries/cloudpulse/metrics.ts index f83b7fd98f9..35983830f8c 100644 --- a/packages/manager/src/queries/cloudpulse/metrics.ts +++ b/packages/manager/src/queries/cloudpulse/metrics.ts @@ -52,12 +52,12 @@ export const useCloudPulseMetricsQuery = ( const currentJWEtokenCache: | JWEToken | undefined = queryClient.getQueryData( - queryFactory.token(serviceType, { resource_ids: [] }).queryKey + queryFactory.token(serviceType, { entity_ids: [] }).queryKey ); if (currentJWEtokenCache?.token === obj.authToken) { queryClient.invalidateQueries( { - queryKey: queryFactory.token(serviceType, { resource_ids: [] }) + queryKey: queryFactory.token(serviceType, { entity_ids: [] }) .queryKey, }, { diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index dbd1fac2c14..7febca23c3a 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -100,6 +100,6 @@ export const queryFactory = createQueryKeys(key, { token: (serviceType: string | undefined, request: JWETokenPayLoad) => ({ queryFn: () => getJWEToken(request, serviceType!), - queryKey: [serviceType, { resource_ids: request.resource_ids.sort() }], + queryKey: [serviceType, { resource_ids: request.entity_ids.sort() }], }), }); From 5cd68412638e2483252d047bfd4dd488dd90e8c6 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Tue, 14 Jan 2025 21:29:25 +0530 Subject: [PATCH 14/42] refactor: [M3-8252] - Remove ramda from `CreateDomain.tsx` (#11505) * Remove ramda from `CreateDomain` * Add changeset --- .../pr-11505-tech-stories-1736510197103.md | 5 +++++ .../Domains/CreateDomain/CreateDomain.tsx | 22 +++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-11505-tech-stories-1736510197103.md diff --git a/packages/manager/.changeset/pr-11505-tech-stories-1736510197103.md b/packages/manager/.changeset/pr-11505-tech-stories-1736510197103.md new file mode 100644 index 00000000000..a94cf71cac3 --- /dev/null +++ b/packages/manager/.changeset/pr-11505-tech-stories-1736510197103.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Tech Stories +--- + +Remove ramda from `CreateDomain.tsx` ([#11505](https://github.com/linode/manager/pull/11505)) diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index f75c545105d..b8d47ad83a5 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -13,7 +13,6 @@ import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; -import { path } from 'ramda'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -201,8 +200,8 @@ export const CreateDomain = () => { return generateDefaultDomainRecords( domainData.domain, domainData.id, - path(['ipv4', 0], selectedDefaultLinode), - path(['ipv6'], selectedDefaultLinode) + selectedDefaultLinode?.ipv4?.[0], + selectedDefaultLinode?.ipv6 ) .then(() => { return redirectToLandingOrDetail(type, domainData.id); @@ -212,8 +211,8 @@ export const CreateDomain = () => { `Default DNS Records couldn't be created from Linode: ${e[0].reason}`, { domainID: domainData.id, - ipv4: path(['ipv4', 0], selectedDefaultLinode), - ipv6: path(['ipv6'], selectedDefaultLinode), + ipv4: selectedDefaultLinode?.ipv4?.[0], + ipv6: selectedDefaultLinode?.ipv6, selectedLinode: selectedDefaultLinode!.id, } ); @@ -228,8 +227,8 @@ export const CreateDomain = () => { return generateDefaultDomainRecords( domainData.domain, domainData.id, - path(['ipv4'], selectedDefaultNodeBalancer), - path(['ipv6'], selectedDefaultNodeBalancer) + selectedDefaultNodeBalancer?.ipv4, + selectedDefaultNodeBalancer?.ipv6 ) .then(() => { return redirectToLandingOrDetail(type, domainData.id); @@ -239,8 +238,8 @@ export const CreateDomain = () => { `Default DNS Records couldn't be created from NodeBalancer: ${e[0].reason}`, { domainID: domainData.id, - ipv4: path(['ipv4'], selectedDefaultNodeBalancer), - ipv6: path(['ipv6'], selectedDefaultNodeBalancer), + ipv4: selectedDefaultNodeBalancer?.ipv4, + ipv6: selectedDefaultNodeBalancer?.ipv6, selectedNodeBalancer: selectedDefaultNodeBalancer!.id, } ); @@ -279,10 +278,9 @@ export const CreateDomain = () => { }; const updatePrimaryIPAddress = (newIPs: ExtendedIP[]) => { - const master_ips = - newIPs.length > 0 ? newIPs.map(extendedIPToString) : ['']; + const masterIps = newIPs.length > 0 ? newIPs.map(extendedIPToString) : ['']; if (mounted) { - formik.setFieldValue('master_ips', master_ips); + formik.setFieldValue('master_ips', masterIps); } }; From 131df59971022ed4209fd671f66304ba05868bab Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Tue, 14 Jan 2025 08:03:08 -0800 Subject: [PATCH 15/42] fix: [M3-9009] - Fix spacing for LKE cluster tags (#11507) * Fix spacing for cluster tags * Added changeset: Spacing for LKE cluster tags at desktop screen sizes --- packages/manager/.changeset/pr-11507-fixed-1736533913571.md | 5 +++++ .../KubernetesClusterDetail/KubeEntityDetailFooter.tsx | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11507-fixed-1736533913571.md diff --git a/packages/manager/.changeset/pr-11507-fixed-1736533913571.md b/packages/manager/.changeset/pr-11507-fixed-1736533913571.md new file mode 100644 index 00000000000..baab0a1e6c9 --- /dev/null +++ b/packages/manager/.changeset/pr-11507-fixed-1736533913571.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Spacing for LKE cluster tags at desktop screen sizes ([#11507](https://github.com/linode/manager/pull/11507)) diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx index 01ad9644511..b0db7e4173c 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx @@ -107,7 +107,7 @@ export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { }, }} alignItems="flex-start" - lg={10} + lg="auto" xs={12} > @@ -165,7 +165,8 @@ export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { justifyContent: 'flex-start', }, }} - lg={2} + lg={3.5} + marginLeft="auto" xs={12} > Date: Tue, 14 Jan 2025 12:29:14 -0500 Subject: [PATCH 16/42] refactor: [M3-9053] - Reroute Longview (#11490) * Initial commit * useTabs hooks improvements * improve hook * default redirect * Update hook with ref * tablist! * cleanup & tests * moar cleanup * feedback @mjac0bs * Post rebase fix --- packages/manager/.eslintrc.cjs | 15 +- packages/manager/src/MainContent.tsx | 2 - .../components/Tabs/TanStackTabLinkList.tsx | 39 ++++ .../DomainRecords/DomainRecords.tsx | 1 + .../ActiveConnections.test.tsx | 19 +- .../ActiveConnections/ActiveConnections.tsx | 149 ++++++------- .../ListeningServices/ListeningServices.tsx | 195 +++++++++--------- .../Processes/ProcessesTable.test.tsx | 15 +- .../DetailTabs/Processes/ProcessesTable.tsx | 174 ++++++++-------- .../DetailTabs/TopProcesses.test.tsx | 59 +++--- .../DetailTabs/TopProcesses.tsx | 115 ++++++----- .../LongviewDetail/LongviewDetail.tsx | 91 +++----- .../LongviewLanding/LongviewClients.tsx | 35 ++-- .../LongviewLanding/LongviewLanding.test.tsx | 26 ++- .../LongviewLanding/LongviewLanding.tsx | 90 +++----- .../manager/src/features/Longview/index.tsx | 36 ---- .../manager/src/hooks/useOrderV2.test.tsx | 2 +- packages/manager/src/hooks/useOrderV2.ts | 27 ++- packages/manager/src/hooks/useTabs.ts | 61 ++++++ packages/manager/src/mocks/serverHandlers.ts | 5 +- packages/manager/src/routes/index.tsx | 1 + .../src/routes/longview/LongviewRoute.tsx | 2 + packages/manager/src/routes/longview/index.ts | 70 ++++--- .../routes/longview/longviewLazyRoutes.tsx | 31 +++ packages/manager/src/routes/routes.test.tsx | 87 -------- packages/manager/src/routes/utils/allPaths.ts | 29 --- .../manager/src/utilities/testHelpers.tsx | 6 +- 27 files changed, 690 insertions(+), 692 deletions(-) create mode 100644 packages/manager/src/components/Tabs/TanStackTabLinkList.tsx delete mode 100644 packages/manager/src/features/Longview/index.tsx create mode 100644 packages/manager/src/hooks/useTabs.ts create mode 100644 packages/manager/src/routes/longview/longviewLazyRoutes.tsx delete mode 100644 packages/manager/src/routes/routes.test.tsx delete mode 100644 packages/manager/src/routes/utils/allPaths.ts diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index cc85bbb89d0..291f9ec2e25 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -91,6 +91,7 @@ module.exports = { // for each new features added to the migration router, add its directory here 'src/features/Betas/**/*', 'src/features/Domains/**/*', + 'src/features/Longview/**/*', 'src/features/Volumes/**/*', ], rules: { @@ -119,14 +120,20 @@ module.exports = { 'withRouter', ], message: - 'Please use routing utilities from @tanstack/react-router.', + 'Please use routing utilities intended for @tanstack/react-router.', name: 'react-router-dom', }, { - importNames: ['renderWithTheme'], + importNames: ['TabLinkList'], message: - 'Please use the wrapWithThemeAndRouter helper function for testing components being migrated to TanStack Router.', - name: 'src/utilities/testHelpers', + 'Please use the TanStackTabLinkList component for components being migrated to TanStack Router.', + name: 'src/components/Tabs/TabLinkList', + }, + { + importNames: ['OrderBy', 'default'], + message: + 'Please use useOrderV2 hook for components being migrated to TanStack Router.', + name: 'src/components/OrderBy', }, ], }, diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index dcdae2ec607..8a8628951f4 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -159,7 +159,6 @@ const SupportTicketDetail = React.lazy(() => }) ) ); -const Longview = React.lazy(() => import('src/features/Longview')); const Managed = React.lazy(() => import('src/features/Managed/ManagedLanding')); const Help = React.lazy(() => import('./features/Help/index').then((module) => ({ @@ -339,7 +338,6 @@ export const MainContent = () => { path="/nodebalancers" /> - { + return ( + + {tabs.map((tab, _index) => { + return ( + + {tab.title} + {tab.chip} + + ); + })} + + ); +}; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx index b33d4f986a7..d0d824f5bc3 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +// eslint-disable-next-line no-restricted-imports import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx index 186ed33aff8..162f5d86581 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.test.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import { longviewPortFactory } from 'src/factories/longviewService'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { ActiveConnections } from './ActiveConnections'; + import type { TableProps } from './ActiveConnections'; const mockConnections = longviewPortFactory.buildList(10); @@ -14,8 +15,8 @@ const props: TableProps = { }; describe('ActiveConnections (and by extension ListeningServices)', () => { - it('should render a table with one row per active connection', () => { - const { queryAllByTestId } = renderWithTheme( + it('should render a table with one row per active connection', async () => { + const { queryAllByTestId } = await renderWithThemeAndRouter( ); expect(queryAllByTestId('longview-connection-row')).toHaveLength( @@ -23,22 +24,22 @@ describe('ActiveConnections (and by extension ListeningServices)', () => { ); }); - it('should render a loading state', () => { - const { getByTestId } = renderWithTheme( + it('should render a loading state', async () => { + const { getByTestId } = await renderWithThemeAndRouter( ); getByTestId('table-row-loading'); }); - it('should render an empty state', () => { - const { getByTestId } = renderWithTheme( + it('should render an empty state', async () => { + const { getByTestId } = await renderWithThemeAndRouter( ); getByTestId('table-row-empty'); }); - it('should render an error state', () => { - const { getByTestId, getByText } = renderWithTheme( + it('should render an error state', async () => { + const { getByTestId, getByText } = await renderWithThemeAndRouter( { export const ConnectionsTable = (props: TableProps) => { const { connections, connectionsError, connectionsLoading } = props; + const { + handleOrderChange, + order, + orderBy, + sortedData, + } = useOrderV2({ + data: connections, + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'process', + }, + from: '/longview/clients/$id/overview', + }, + preferenceKey: 'active-connections', + prefix: 'active-connections', + }); + return ( - - {({ data: orderedData, handleOrderChange, order, orderBy }) => ( - - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => ( - <> - - - - - Name - - - User - - - Count - - - - - {renderLoadingErrorData( - connectionsLoading, - paginatedData, - connectionsError - )} - -
    - - - )} -
    + + {({ + count, + data: paginatedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + + Name + + + User + + + Count + + + + + {renderLoadingErrorData( + connectionsLoading, + paginatedData ?? [], + connectionsError + )} + +
    + + )} -
    + ); }; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx index 4234d9f65e4..0de96fbbd88 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx @@ -2,7 +2,6 @@ import { Typography } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; 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'; @@ -13,6 +12,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; import { LongviewServiceRow } from './LongviewServiceRow'; @@ -51,100 +51,109 @@ export const ListeningServices = (props: TableProps) => { export const ServicesTable = (props: TableProps) => { const { services, servicesError, servicesLoading } = props; + const { + handleOrderChange, + order, + orderBy, + sortedData, + } = useOrderV2({ + data: services, + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'process', + }, + from: '/longview/clients/$id/overview', + }, + preferenceKey: 'listening-services', + prefix: 'listening-services', + }); + return ( - - {({ data: orderedData, handleOrderChange, order, orderBy }) => ( - - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => ( - <> - - - - - Process - - - User - - - Protocol - - - Port - - - IP - - - - - {renderLoadingErrorData( - servicesLoading, - paginatedData, - servicesError - )} - -
    - - - )} -
    + + {({ + count, + data: paginatedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + + Process + + + User + + + Protocol + + + Port + + + IP + + + + + {renderLoadingErrorData( + servicesLoading, + paginatedData, + servicesError + )} + +
    + + )} -
    + ); }; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx index b70650ae963..95546034105 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.test.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import { longviewProcessFactory } from 'src/factories/longviewProcess'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { extendData } from './ProcessesLanding'; import { ProcessesTable } from './ProcessesTable'; + import type { ProcessesTableProps } from './ProcessesTable'; const mockSetSelectedRow = vi.fn(); @@ -19,8 +20,8 @@ const props: ProcessesTableProps = { describe('ProcessTable', () => { const extendedData = extendData(longviewProcessFactory.build()); - it('renders all columns for each row', () => { - const { getAllByTestId, getAllByText } = renderWithTheme( + it('renders all columns for each row', async () => { + const { getAllByTestId, getAllByText } = await renderWithThemeAndRouter( ); extendedData.forEach((row) => { @@ -33,15 +34,15 @@ describe('ProcessTable', () => { }); }); - it('renders loading state', () => { - const { getByTestId } = renderWithTheme( + it('renders loading state', async () => { + const { getByTestId } = await renderWithThemeAndRouter( ); getByTestId('table-row-loading'); }); - it('renders error state', () => { - const { getByText } = renderWithTheme( + it('renders error state', async () => { + const { getByText } = await renderWithThemeAndRouter( ); getByText('Error!'); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx index 6862e19c93d..d1d94eff487 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; -import OrderBy from 'src/components/OrderBy'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; @@ -11,6 +10,7 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { formatCPU } from 'src/features/Longview/shared/formatters'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; import { readableBytes } from 'src/utilities/unitConversions'; @@ -39,90 +39,98 @@ export const ProcessesTable = React.memo((props: ProcessesTableProps) => { setSelectedProcess, } = props; + const { + handleOrderChange, + order, + orderBy, + sortedData, + } = useOrderV2({ + data: processesData, + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'name', + }, + from: '/longview/clients/$id/processes', + }, + preferenceKey: 'lv-detail-processes', + }); + return ( - = 1280} + spacingTop={16} > - {({ data: orderedData, handleOrderChange, order, orderBy }) => ( - = 1280} - spacingTop={16} - > - - - - Process - - - User - - - Max Count - - - Avg IO - - - Avg CPU - - - Avg Mem - - - - - {renderLoadingErrorData( - processesLoading, - orderedData, - selectedProcess, - setSelectedProcess, - error - )} - - - )} - + + + + Process + + + User + + + Max Count + + + Avg IO + + + Avg CPU + + + Avg Mem + + + + + {renderLoadingErrorData( + processesLoading, + sortedData ?? [], + selectedProcess, + setSelectedProcess, + error + )} + + ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx index 8c6aec75224..a862e521501 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.test.tsx @@ -1,11 +1,12 @@ -import { render } from '@testing-library/react'; import * as React from 'react'; import { longviewTopProcessesFactory } from 'src/factories/longviewTopProcesses'; -import { LongviewTopProcesses } from 'src/features/Longview/request.types'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; -import { Props, TopProcesses, extendTopProcesses } from './TopProcesses'; +import { TopProcesses, extendTopProcesses } from './TopProcesses'; + +import type { Props } from './TopProcesses'; +import type { LongviewTopProcesses } from 'src/features/Longview/request.types'; const props: Props = { clientID: 1, @@ -19,54 +20,52 @@ describe('Top Processes', () => { vi.clearAllMocks(); }); - it('renders the title', () => { - const { getByText } = render(wrapWithTheme()); + it('renders the title', async () => { + const { getByText } = await renderWithThemeAndRouter( + + ); getByText('Top Processes'); }); - it('renders the View Details link', () => { - const { queryByText } = render( - wrapWithTheme() + it('renders the View Details link', async () => { + const { queryByText } = await renderWithThemeAndRouter( + ); expect(queryByText('View Details')).toBeDefined(); }); - it('renders rows for each process', () => { + it('renders rows for each process', async () => { const data = longviewTopProcessesFactory.build(); // The component renders a maximum of 6 rows. Assert our test data has // fewer than seven processes so the test is valid. expect(Object.keys(data.Processes || {}).length).toBeLessThan(7); - const { getByText } = render( - wrapWithTheme() + const { getByText } = await renderWithThemeAndRouter( + ); Object.keys(data.Processes || {}).forEach((processName) => { getByText(processName); }); }); - it('renders loading state', () => { - const { getAllByTestId } = render( - wrapWithTheme( - - ) + it('renders loading state', async () => { + const { getAllByTestId } = await renderWithThemeAndRouter( + ); getAllByTestId('table-row-loading'); }); - it('renders error state', () => { - const { getByText } = render( - wrapWithTheme( - - ) + it('renders error state', async () => { + const { getByText } = await renderWithThemeAndRouter( + ); getByText('There was an error getting Top Processes.'); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx index f988ef09c16..eb12587cff3 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx @@ -1,7 +1,6 @@ import { Box, Typography } from '@linode/ui'; import * as React from 'react'; -import OrderBy from 'src/components/OrderBy'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -11,6 +10,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; import { readableBytes } from 'src/utilities/unitConversions'; import { formatCPU } from '../../shared/formatters'; @@ -40,6 +40,24 @@ export const TopProcesses = React.memo((props: Props) => { topProcessesLoading, } = props; + const { + handleOrderChange, + order, + orderBy, + sortedData, + } = useOrderV2({ + data: extendTopProcesses(topProcessesData), + initialRoute: { + defaultOrder: { + order: 'desc', + orderBy: 'cpu', + }, + from: '/longview/clients/$id/overview', + }, + preferenceKey: 'top-processes', + prefix: 'top-processes', + }); + const errorMessage = Boolean(topProcessesError || lastUpdatedError) ? 'There was an error getting Top Processes.' : undefined; @@ -52,58 +70,49 @@ export const TopProcesses = React.memo((props: Props) => { View Details
    - - {({ data: orderedData, handleOrderChange, order, orderBy }) => ( - - - - - Process - - - CPU - - - Memory - - - - - {renderLoadingErrorData( - orderedData, - topProcessesLoading, - errorMessage - )} - -
    - )} -
    + + + + + Process + + + CPU + + + Memory + + + + + {renderLoadingErrorData( + sortedData ?? [], + topProcessesLoading, + errorMessage + )} + +
    ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx index e95e5130623..c1c166f33cf 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx @@ -1,8 +1,6 @@ import { CircleProgress, Notice, Paper } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; import { pathOr } from 'ramda'; import * as React from 'react'; -import { matchPath } from 'react-router-dom'; import { compose } from 'recompose'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -10,12 +8,13 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; -import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import withLongviewClients from 'src/containers/longview.container'; import withClientStats from 'src/containers/longview.stats.container'; import { get } from 'src/features/Longview/request'; import { useAPIRequest } from 'src/hooks/useAPIRequest'; +import { useTabs } from 'src/hooks/useTabs'; import { useProfile } from 'src/queries/profile/profile'; import { useClientLastUpdated } from '../shared/useClientLastUpdated'; @@ -27,7 +26,6 @@ import { ProcessesLanding } from './DetailTabs/Processes/ProcessesLanding'; import { StyledTabs } from './LongviewDetail.styles'; import type { LongviewClient } from '@linode/api-v4/lib/longview'; -import type { RouteComponentProps } from 'react-router-dom'; import type { DispatchProps, Props as LVProps, @@ -53,10 +51,7 @@ const Overview = React.lazy( const Installation = React.lazy(() => import('./DetailTabs/Installation')); const Disks = React.lazy(() => import('./DetailTabs/Disks/Disks')); -export type CombinedProps = RouteComponentProps<{ id: string }> & - Props & - LVDataProps & - DispatchProps; +export type CombinedProps = Props & LVDataProps & DispatchProps; export const LongviewDetail = (props: CombinedProps) => { const { @@ -111,59 +106,43 @@ export const LongviewDetail = (props: CombinedProps) => { [clientAPIKey, lastUpdated] ); - const tabOptions = [ + const { handleTabChange, tabIndex, tabs } = useTabs([ { - display: true, - routeName: `${props.match.url}/overview`, title: 'Overview', + to: '/longview/clients/$id/overview', }, { - display: true, - routeName: `${props.match.url}/processes`, title: 'Processes', + to: '/longview/clients/$id/processes', }, { - display: true, - routeName: `${props.match.url}/network`, title: 'Network', + to: '/longview/clients/$id/network', }, { - display: true, - routeName: `${props.match.url}/disks`, title: 'Disks', + to: '/longview/clients/$id/disks', }, { - display: client && client.apps.apache, - routeName: `${props.match.url}/apache`, + hide: !client?.apps.apache, title: 'Apache', + to: '/longview/clients/$id/apache', }, { - display: client && client.apps.nginx, - routeName: `${props.match.url}/nginx`, + hide: !client?.apps.nginx, title: 'Nginx', + to: '/longview/clients/$id/nginx', }, { - display: client && client.apps.mysql, - routeName: `${props.match.url}/mysql`, + hide: !client?.apps.mysql, title: 'MySQL', + to: '/longview/clients/$id/mysql', }, { - display: true, - routeName: `${props.match.url}/installation`, title: 'Installation', + to: '/longview/clients/$id/installation', }, - ]; - - // Filtering out conditional tabs if they don't exist on client - const tabs = tabOptions.filter((tab) => tab.display === true); - - const matches = (p: string) => { - return Boolean(matchPath(p, { path: props.location.pathname })); - }; - - const navToURL = (index: number) => { - props.history.push(tabs[index].routeName); - }; + ]); if (longviewClientsLoading && longviewClientsLastUpdated === 0) { return ( @@ -194,16 +173,14 @@ export const LongviewDetail = (props: CombinedProps) => { return null; } - // Determining true tab count for indexing based on tab display - const displayedTabs = tabs.filter((tab) => tab.display === true); - return ( { variant="warning" /> ))} - matches(tab.routeName)), - 0 - )} - onChange={navToURL} - > - + + }> @@ -311,7 +282,7 @@ export const LongviewDetail = (props: CombinedProps) => { )} - + { ); }; +type LongviewDetailParams = { + id: string; +}; + const EnhancedLongviewDetail = compose( React.memo, - withClientStats>((ownProps) => { + withClientStats<{ params: LongviewDetailParams }>((ownProps) => { return +pathOr('', ['match', 'params', 'id'], ownProps); }), - withLongviewClients>( + withLongviewClients( ( own, { @@ -354,16 +329,4 @@ const EnhancedLongviewDetail = compose( ) )(LongviewDetail); -export const longviewDetailLazyRoute = createLazyRoute('/longview/clients/$id')( - { - component: React.lazy(() => - import('src/features/Longview/LongviewDetail/LongviewDetail').then( - () => ({ - default: (props: any) => , - }) - ) - ), - } -); - export default EnhancedLongviewDetail; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx index b3037b0411e..ef356281aec 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx @@ -1,12 +1,13 @@ import { Autocomplete, Typography } from '@linode/ui'; +import { useLocation, useNavigate } from '@tanstack/react-router'; import { isEmpty, pathOr } from 'ramda'; import * as React from 'react'; import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; import { compose } from 'recompose'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { Link } from 'src/components/Link'; import withLongviewClients from 'src/containers/longview.container'; import { useAccountSettings } from 'src/queries/account/settings'; import { useGrants, useProfile } from 'src/queries/profile/profile'; @@ -31,8 +32,8 @@ import type { LongviewClient, LongviewSubscription, } from '@linode/api-v4/lib/longview/types'; -import type { RouteComponentProps } from 'react-router-dom'; import type { Props as LongviewProps } from 'src/containers/longview.container'; +import type { LongviewState } from 'src/routes/longview'; import type { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; import type { MapState } from 'src/store/types'; @@ -47,16 +48,15 @@ interface SortOption { value: SortKey; } -export type LongviewClientsCombinedProps = Props & - RouteComponentProps & - LongviewProps & - StateProps; +export type LongviewClientsCombinedProps = Props & LongviewProps & StateProps; type SortKey = 'cpu' | 'load' | 'name' | 'network' | 'ram' | 'storage' | 'swap'; export const LongviewClients = (props: LongviewClientsCombinedProps) => { const { getLongviewClients } = props; - + const navigate = useNavigate(); + const location = useLocation(); + const locationState = location.state as LongviewState; const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { data: accountSettings } = useAccountSettings(); @@ -130,21 +130,16 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { }, []); const handleSubmit = () => { - const { - history: { push }, - } = props; - if (isManaged) { - push({ - pathname: '/support/tickets', - state: { - open: true, - title: 'Request for additional Longview clients', - }, + navigate({ + state: (prev) => ({ ...prev, ...locationState }), + to: '/support/tickets', }); return; } - props.history.push('/longview/plan-details'); + navigate({ + to: '/longview/plan-details', + }); }; /** @@ -299,9 +294,7 @@ const mapStateToProps: MapState = (state, _ownProps) => { const connected = connect(mapStateToProps); -interface ComposeProps extends Props, RouteComponentProps {} - -export default compose( +export default compose( React.memo, connected, withLongviewClients() diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx index 0035b1657b2..69fa4a44863 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.test.tsx @@ -6,7 +6,7 @@ import { longviewClientFactory, longviewSubscriptionFactory, } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { LongviewClients, @@ -103,19 +103,23 @@ describe('Utility Functions', () => { }); describe('Longview clients list view', () => { - it('should request clients on load', () => { - renderWithTheme(); + it('should request clients on load', async () => { + await renderWithThemeAndRouter(); expect(props.getLongviewClients).toHaveBeenCalledTimes(1); }); it('should have an Add Client button', async () => { - const { findByText } = renderWithTheme(); + const { findByText } = await renderWithThemeAndRouter( + + ); const addButton = await findByText('Add Client'); expect(addButton).toBeInTheDocument(); }); it('should attempt to add a new client when the Add Client button is clicked', async () => { - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + + ); const button = getByText('Add Client'); fireEvent.click(button); await waitFor(() => @@ -123,8 +127,8 @@ describe('Longview clients list view', () => { ); }); - it('should render a row for each client', () => { - const { queryAllByTestId } = renderWithTheme( + it('should render a row for each client', async () => { + const { queryAllByTestId } = await renderWithThemeAndRouter( ); @@ -133,16 +137,16 @@ describe('Longview clients list view', () => { ); }); - it('should render a CTA for non-Pro subscribers', () => { - const { getByText } = renderWithTheme( + it('should render a CTA for non-Pro subscribers', async () => { + const { getByText } = await renderWithThemeAndRouter( ); getByText(/upgrade to longview pro/i); }); - it('should not render a CTA for LV Pro subscribers', () => { - const { queryAllByText } = renderWithTheme( + it('should not render a CTA for LV Pro subscribers', async () => { + const { queryAllByText } = await renderWithThemeAndRouter( import('./LongviewClients')); const LongviewPlans = React.lazy(() => import('./LongviewPlans')); -interface LongviewLandingProps extends LongviewProps, RouteComponentProps<{}> {} - -export const LongviewLanding = (props: LongviewLandingProps) => { +export const LongviewLanding = (props: LongviewProps) => { + const navigate = useNavigate(); + const location = useLocation(); + const locationState = location.state as LongviewState; const { enqueueSnackbar } = useSnackbar(); const activeSubscriptionRequestHook = useAPIRequest( () => getActiveLongviewPlan().then((response) => response), @@ -61,37 +62,30 @@ export const LongviewLanding = (props: LongviewLandingProps) => { setSubscriptionDialogOpen, ] = React.useState(false); - const tabs = [ - /* NB: These must correspond to the routes inside the Switch */ + const { handleTabChange, tabIndex, tabs } = useTabs([ { - routeName: `${props.match.url}/clients`, title: 'Clients', + to: '/longview/clients', }, { - routeName: `${props.match.url}/plan-details`, title: 'Plan Details', + to: '/longview/plan-details', }, - ]; + ]); const isLongviewCreationRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_longview', }); - const matches = (p: string) => { - return Boolean(matchPath(p, { path: props.location.pathname })); - }; - - const navToURL = (index: number) => { - props.history.push(tabs[index].routeName); - }; - const handleAddClient = () => { setNewClientLoading(true); createLongviewClient() .then((_) => { setNewClientLoading(false); - if (props.history.location.pathname !== '/longview/clients') { - props.history.push('/longview/clients'); + if (location.pathname !== '/longview/clients') { + navigate({ + to: '/longview/clients', + }); } }) .catch((errorResponse) => { @@ -113,34 +107,21 @@ export const LongviewLanding = (props: LongviewLandingProps) => { }; const handleSubmit = () => { - const { - history: { push }, - } = props; - if (isManaged) { - push({ - pathname: '/support/tickets', - state: { - open: true, - title: 'Request for additional Longview clients', - }, + navigate({ + state: (prev) => ({ ...prev, ...locationState }), + to: '/support/tickets', }); return; } - props.history.push('/longview/plan-details'); + navigate({ + to: '/longview/plan-details', + }); }; return ( <> { resourceType: 'Longview Clients', }), }} + createButtonText="Add Client" + disabledCreateButton={isLongviewCreationRestricted} + docsLink="https://techdocs.akamai.com/cloud-computing/docs/getting-started-with-longview" + entity="Client" + loading={newClientLoading} + onButtonClick={handleAddClient} + removeCrumbX={1} + title="Longview" /> - matches(tab.routeName)), - 0 - )} - onChange={navToURL} - > - - + + }> @@ -199,8 +181,4 @@ const StyledTabs = styled(Tabs, { marginTop: 0, })); -export const longviewLandingLazyRoute = createLazyRoute('/longview')({ - component: LongviewLanding, -}); - export default withLongviewClients()(LongviewLanding); diff --git a/packages/manager/src/features/Longview/index.tsx b/packages/manager/src/features/Longview/index.tsx deleted file mode 100644 index 6d2950f8e90..00000000000 --- a/packages/manager/src/features/Longview/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react'; -import { Route, RouteComponentProps, Switch } from 'react-router-dom'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; -import { SuspenseLoader } from 'src/components/SuspenseLoader'; - -const LongviewLanding = React.lazy( - () => import('./LongviewLanding/LongviewLanding') -); -const LongviewDetail = React.lazy( - () => import('./LongviewDetail/LongviewDetail') -); - -type Props = RouteComponentProps<{}>; - -const Longview = (props: Props) => { - const { - match: { path }, - } = props; - - return ( - - - - }> - - - - - - - ); -}; - -export default Longview; diff --git a/packages/manager/src/hooks/useOrderV2.test.tsx b/packages/manager/src/hooks/useOrderV2.test.tsx index 6e06c77020e..03cce394215 100644 --- a/packages/manager/src/hooks/useOrderV2.test.tsx +++ b/packages/manager/src/hooks/useOrderV2.test.tsx @@ -21,7 +21,7 @@ vi.mock('@tanstack/react-router', async () => { }); const queryClient = queryClientFactory(); -const defaultProps: UseOrderV2Props = { +const defaultProps: UseOrderV2Props = { initialRoute: { defaultOrder: { order: 'asc', diff --git a/packages/manager/src/hooks/useOrderV2.ts b/packages/manager/src/hooks/useOrderV2.ts index 5e51f3345b6..74fb129713a 100644 --- a/packages/manager/src/hooks/useOrderV2.ts +++ b/packages/manager/src/hooks/useOrderV2.ts @@ -1,5 +1,7 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; +import React from 'react'; +import { sortData } from 'src/components/OrderBy'; import { useMutatePreferences, usePreferences, @@ -11,7 +13,16 @@ import type { OrderSetWithPrefix } from 'src/types/ManagerPreferences'; export type Order = 'asc' | 'desc'; -export interface UseOrderV2Props { +export interface UseOrderV2Props { + /** + * data to sort + * This is an optional prop to sort client side data, + * when useOrderV2 isn't used to just provide a sort order for our queries. + * + * We usually would rather add to sorting as a param to the query, + * but in some cases the endpoint won't allow it, or we can't get around inheriting the data from a parent component. + */ + data?: T[]; /** * initial order to use when no query params are present * Includes the from and search params @@ -43,16 +54,17 @@ export interface UseOrderV2Props { * When a user changes order using the handleOrderChange function, the query params are * updated and the user preferences are also updated. */ -export const useOrderV2 = ({ +export const useOrderV2 = ({ + data, initialRoute, preferenceKey, prefix, -}: UseOrderV2Props) => { +}: UseOrderV2Props) => { const { data: orderPreferences } = usePreferences( (preferences) => preferences?.sortKeys ); const { mutateAsync: updatePreferences } = useMutatePreferences(); - const searchParams = useSearch({ from: initialRoute.from }); + const searchParams = useSearch({ strict: false }); const navigate = useNavigate(); const getOrderValues = () => { @@ -118,5 +130,10 @@ export const useOrderV2 = ({ }); }; - return { handleOrderChange, order, orderBy }; + const sortedData = React.useMemo( + () => (data ? sortData(orderBy, order)(data) : null), + [data, orderBy, order] + ); + + return { handleOrderChange, order, orderBy, sortedData }; }; diff --git a/packages/manager/src/hooks/useTabs.ts b/packages/manager/src/hooks/useTabs.ts new file mode 100644 index 00000000000..05a861f4341 --- /dev/null +++ b/packages/manager/src/hooks/useTabs.ts @@ -0,0 +1,61 @@ +import { useMatchRoute } from '@tanstack/react-router'; +import * as React from 'react'; + +import type { LinkProps } from '@tanstack/react-router'; + +export interface Tab { + /** + * The chip to display in the tab (a helper icon if disabled for instance). + */ + chip?: React.JSX.Element | null; + /** + * Whether the tab is disabled. + */ + disabled?: boolean; + /** + * Whether the tab is hidden. + */ + hide?: boolean; + /** + * The icon to display in the tab (a helper icon if disabled for instance). + */ + icon?: React.ReactNode; + /** + * The title of the tab. + */ + title: string; + /** + * The path to navigate to when the tab is clicked. + */ + to: LinkProps['to']; +} + +/** + * This hook is a necessary evil to sync routing and tabs, + * since Reach Tabs maintains its own index state. + */ +export function useTabs(tabs: T[]) { + const matchRoute = useMatchRoute(); + + // Filter out hidden tabs + const visibleTabs = React.useMemo(() => tabs.filter((tab) => !tab.hide), [ + tabs, + ]); + + // Calculate current index based on route + const tabIndex = React.useMemo(() => { + const index = visibleTabs.findIndex((tab) => matchRoute({ to: tab.to })); + return index === -1 ? 0 : index; + }, [visibleTabs, matchRoute]); + + // Simple handler to satisfy Reach Tabs props + const handleTabChange = React.useCallback(() => { + // No-op - navigation is handled by Tanstack Router `Link` + }, []); + + return { + handleTabChange, + tabIndex, + tabs: visibleTabs, + }; +} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 44634a9e948..d5e31f857b4 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1782,13 +1782,16 @@ export const handlers = [ return HttpResponse.json({}); }), http.get('*/longview/plan', () => { - const plan = longviewActivePlanFactory.build(); + const plan = longviewActivePlanFactory.build({}); return HttpResponse.json(plan); }), http.get('*/longview/subscriptions', () => { const subscriptions = longviewSubscriptionFactory.buildList(10); return HttpResponse.json(makeResourcePage(subscriptions)); }), + http.post('https://longview.linode.com/fetch', () => { + return HttpResponse.json({}); + }), http.get('*/longview/clients', () => { const clients = longviewClientFactory.buildList(10); return HttpResponse.json(makeResourcePage(clients)); diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 9aa1261c7a6..8fd5ed337ae 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -87,6 +87,7 @@ declare module '@tanstack/react-router' { export const migrationRouteTree = migrationRootRoute.addChildren([ betaRouteTree, domainsRouteTree, + longviewRouteTree, volumesRouteTree, ]); export type MigrationRouteTree = typeof migrationRouteTree; diff --git a/packages/manager/src/routes/longview/LongviewRoute.tsx b/packages/manager/src/routes/longview/LongviewRoute.tsx index 57907e7f20d..df2ba7547cb 100644 --- a/packages/manager/src/routes/longview/LongviewRoute.tsx +++ b/packages/manager/src/routes/longview/LongviewRoute.tsx @@ -1,12 +1,14 @@ import { Outlet } from '@tanstack/react-router'; import React from 'react'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; export const LongviewRoute = () => { return ( }> + diff --git a/packages/manager/src/routes/longview/index.ts b/packages/manager/src/routes/longview/index.ts index b684180b509..48f3a61668e 100644 --- a/packages/manager/src/routes/longview/index.ts +++ b/packages/manager/src/routes/longview/index.ts @@ -1,8 +1,13 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { LongviewRoute } from './LongviewRoute'; +export type LongviewState = { + open?: boolean; + title?: string; +}; + const longviewRoute = createRoute({ component: LongviewRoute, getParentRoute: () => rootRoute, @@ -10,30 +15,27 @@ const longviewRoute = createRoute({ }); const longviewLandingRoute = createRoute({ + beforeLoad: () => { + throw redirect({ to: '/longview/clients' }); + }, getParentRoute: () => longviewRoute, path: '/', }).lazy(() => - import('src/features/Longview/LongviewLanding/LongviewLanding').then( - (m) => m.longviewLandingLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewLandingLazyRoute) ); const longviewLandingClientsRoute = createRoute({ getParentRoute: () => longviewRoute, path: 'clients', }).lazy(() => - import('src/features/Longview/LongviewLanding/LongviewLanding').then( - (m) => m.longviewLandingLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewLandingLazyRoute) ); const longviewLandingPlanDetailsRoute = createRoute({ getParentRoute: () => longviewRoute, path: 'plan-details', }).lazy(() => - import('src/features/Longview/LongviewLanding/LongviewLanding').then( - (m) => m.longviewLandingLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewLandingLazyRoute) ); const longviewDetailRoute = createRoute({ @@ -43,54 +45,63 @@ const longviewDetailRoute = createRoute({ }), path: 'clients/$id', }).lazy(() => - import('src/features/Longview/LongviewDetail/LongviewDetail').then( - (m) => m.longviewDetailLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) ); const longviewDetailOverviewRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'overview', }).lazy(() => - import('src/features/Longview/LongviewDetail/LongviewDetail').then( - (m) => m.longviewDetailLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) ); const longviewDetailProcessesRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'processes', }).lazy(() => - import('src/features/Longview/LongviewDetail/LongviewDetail').then( - (m) => m.longviewDetailLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) ); const longviewDetailNetworkRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'network', }).lazy(() => - import('src/features/Longview/LongviewDetail/LongviewDetail').then( - (m) => m.longviewDetailLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) ); const longviewDetailDisksRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'disks', }).lazy(() => - import('src/features/Longview/LongviewDetail/LongviewDetail').then( - (m) => m.longviewDetailLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) ); const longviewDetailInstallationRoute = createRoute({ getParentRoute: () => longviewDetailRoute, path: 'installation', }).lazy(() => - import('src/features/Longview/LongviewDetail/LongviewDetail').then( - (m) => m.longviewDetailLazyRoute - ) + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) +); + +const longviewDetailApacheRoute = createRoute({ + getParentRoute: () => longviewDetailRoute, + path: 'apache', +}).lazy(() => + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) +); + +const longviewDetailNginxRoute = createRoute({ + getParentRoute: () => longviewDetailRoute, + path: 'nginx', +}).lazy(() => + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) +); + +const longviewDetailMySQLRoute = createRoute({ + getParentRoute: () => longviewDetailRoute, + path: 'mysql', +}).lazy(() => + import('./longviewLazyRoutes').then((m) => m.longviewDetailLazyRoute) ); export const longviewRouteTree = longviewRoute.addChildren([ @@ -103,5 +114,8 @@ export const longviewRouteTree = longviewRoute.addChildren([ longviewDetailNetworkRoute, longviewDetailDisksRoute, longviewDetailInstallationRoute, + longviewDetailApacheRoute, + longviewDetailNginxRoute, + longviewDetailMySQLRoute, ]), ]); diff --git a/packages/manager/src/routes/longview/longviewLazyRoutes.tsx b/packages/manager/src/routes/longview/longviewLazyRoutes.tsx new file mode 100644 index 00000000000..2e95cce42fc --- /dev/null +++ b/packages/manager/src/routes/longview/longviewLazyRoutes.tsx @@ -0,0 +1,31 @@ +import { createLazyRoute, useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import EnhancedLongviewDetail from 'src/features/Longview/LongviewDetail/LongviewDetail'; +import LongviewLanding from 'src/features/Longview/LongviewLanding/LongviewLanding'; + +export const longviewLandingLazyRoute = createLazyRoute('/longview')({ + component: LongviewLanding, +}); + +// Making a functional component to wrap the EnhancedLongviewDetail HOC +// Ideally we would refactor this and fetch the data properly but considering Longview is nearing its end of life +// we'll just match the legacy routing behavior +const LongviewDetailWrapper = () => { + const { id } = useParams({ from: '/longview/clients/$id' }); + const matchProps = { + match: { + params: { + id, + }, + }, + }; + + return ; +}; + +export const longviewDetailLazyRoute = createLazyRoute('/longview/clients/$id')( + { + component: LongviewDetailWrapper, + } +); diff --git a/packages/manager/src/routes/routes.test.tsx b/packages/manager/src/routes/routes.test.tsx deleted file mode 100644 index b760499ec50..00000000000 --- a/packages/manager/src/routes/routes.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { RouterProvider } from '@tanstack/react-router'; -import { screen, waitFor } from '@testing-library/react'; -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { migrationRouter } from './index'; -import { getAllRoutePaths } from './utils/allPaths'; - -import type { useQuery } from '@tanstack/react-query'; -// TODO: Tanstack Router - replace AnyRouter once migration is complete. -import type { AnyRouter } from '@tanstack/react-router'; - -vi.mock('@tanstack/react-query', async () => { - const actual = await vi.importActual('@tanstack/react-query'); - return { - ...actual, - useQuery: vi - .fn() - .mockImplementation((...args: Parameters) => { - const actualResult = (actual.useQuery as typeof useQuery)(...args); - return { - ...actualResult, - isLoading: false, - }; - }), - }; -}); - -const allMigrationPaths = getAllRoutePaths(migrationRouter); - -describe('Migration Router', () => { - const renderWithRouter = (initialEntry: string) => { - migrationRouter.invalidate(); - migrationRouter.navigate({ replace: true, to: initialEntry }); - - return renderWithTheme( - , - { - flags: { - selfServeBetas: true, - }, - } - ); - }; - - /** - * This test is meant to incrementally test all routes being added to the migration router. - * It will hopefully catch any issues with routes not being added or set up correctly: - * - Route is not found in the router - * - Route is found in the router but the component is not rendered - * - Route is found in the router and the component is rendered but missing a heading (which should be a requirement for all routes) - */ - test.each(allMigrationPaths)('route: %s', async (path) => { - renderWithRouter(path); - - await waitFor( - async () => { - const migrationRouter = screen.getByTestId('migration-router'); - const h1 = screen.getByRole('heading', { level: 1 }); - expect(migrationRouter).toBeInTheDocument(); - expect(h1).toBeInTheDocument(); - expect(h1).not.toHaveTextContent('Not Found'); - }, - { - timeout: 5000, - } - ); - }); - - it('should render the NotFound component for broken routes', async () => { - renderWithRouter('/broken-route'); - - await waitFor( - async () => { - const migrationRouter = screen.getByTestId('migration-router'); - const h1 = screen.getByRole('heading', { level: 1 }); - expect(migrationRouter).toBeInTheDocument(); - expect(h1).toBeInTheDocument(); - expect(h1).toHaveTextContent('Not Found'); - }, - { - timeout: 5000, - } - ); - }); -}); diff --git a/packages/manager/src/routes/utils/allPaths.ts b/packages/manager/src/routes/utils/allPaths.ts deleted file mode 100644 index b27cc54194e..00000000000 --- a/packages/manager/src/routes/utils/allPaths.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AnyRouter } from '@tanstack/react-router'; - -/** - * This function is meant to be used for testing purposes only. - * It allows us to generate a list of all unique @tanstack/router paths defined in the routing factory. - * - * We import this util in routes.test.tsx to loop through all routes and test them. - * It probably should not be used for anything else than testing. - * - * TODO: Tanstack Router - replace AnyRouter once migration is complete. - */ -export const getAllRoutePaths = (router: AnyRouter): string[] => { - return router.flatRoutes - .map((route) => { - let path: string = route.id; - // Replace dynamic segments with placeholders - path = path.replace(/\/\$(\w+)/g, (_, segment) => { - if (segment.toLowerCase().includes('id')) { - return '/1'; - } else { - return `/mock-${segment}`; - } - }); - - return path; - }) - .filter((path) => path !== '/') - .filter(Boolean); -}; diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index 0ae3424c2bf..56a78294b6d 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -15,7 +15,7 @@ import { mergeDeepRight } from 'ramda'; import * as React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { Provider } from 'react-redux'; -import { MemoryRouter, Route } from 'react-router-dom'; +import { BrowserRouter, MemoryRouter, Route } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; @@ -206,7 +206,9 @@ export const wrapWithThemeAndRouter = ( options={{ bootstrap: options.flags }} > - + + + From 3b77ca024dd4054afd7a8c780a4b8f9e1360e308 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:04:07 -0500 Subject: [PATCH 17/42] change: [M3-8956] - Update `tsconfig.json`s to use `bundler` moduleResolution (#11487) * update tsconfigs * add changesets * fix cypress typecheck --------- Co-authored-by: Banks Nussman --- .../.changeset/pr-11487-tech-stories-1736286017541.md | 5 +++++ packages/api-v4/src/request.test.ts | 1 + packages/api-v4/tsconfig.json | 9 ++------- .../.changeset/pr-11487-tech-stories-1736286047440.md | 5 +++++ packages/manager/cypress/tsconfig.json | 1 + packages/manager/src/factories/types.ts | 3 +-- .../Support/SupportTickets/SupportTicketDialog.tsx | 8 +++++--- packages/manager/src/queries/volumes/volumes.ts | 7 +++++-- packages/manager/tsconfig.json | 2 +- packages/search/tsconfig.json | 2 +- .../.changeset/pr-11487-tech-stories-1736286059147.md | 5 +++++ packages/validation/tsconfig.json | 10 +++++----- 12 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11487-tech-stories-1736286017541.md create mode 100644 packages/manager/.changeset/pr-11487-tech-stories-1736286047440.md create mode 100644 packages/validation/.changeset/pr-11487-tech-stories-1736286059147.md diff --git a/packages/api-v4/.changeset/pr-11487-tech-stories-1736286017541.md b/packages/api-v4/.changeset/pr-11487-tech-stories-1736286017541.md new file mode 100644 index 00000000000..b27c1d34f8a --- /dev/null +++ b/packages/api-v4/.changeset/pr-11487-tech-stories-1736286017541.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Tech Stories +--- + +Update `tsconfig.json` to use `bundler` moduleResolution ([#11487](https://github.com/linode/manager/pull/11487)) diff --git a/packages/api-v4/src/request.test.ts b/packages/api-v4/src/request.test.ts index 97ffeb98d7a..f719403dead 100644 --- a/packages/api-v4/src/request.test.ts +++ b/packages/api-v4/src/request.test.ts @@ -1,3 +1,4 @@ +import { beforeEach, describe, vi, expect, it } from 'vitest'; import adapter from 'axios-mock-adapter'; import { object, string } from 'yup'; import request, { diff --git a/packages/api-v4/tsconfig.json b/packages/api-v4/tsconfig.json index 099ebaf3a2e..58df0f1dff7 100644 --- a/packages/api-v4/tsconfig.json +++ b/packages/api-v4/tsconfig.json @@ -1,13 +1,12 @@ { "compilerOptions": { "target": "esnext", - "types": ["vitest/globals"], - "module": "umd", + "module": "esnext", "emitDeclarationOnly": true, "declaration": true, "outDir": "./lib", "esModuleInterop": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "skipLibCheck": true, "strict": true, "baseUrl": ".", @@ -17,9 +16,5 @@ }, "include": [ "src" - ], - "exclude": [ - "node_modules/**/*", - "**/__tests__/*" ] } diff --git a/packages/manager/.changeset/pr-11487-tech-stories-1736286047440.md b/packages/manager/.changeset/pr-11487-tech-stories-1736286047440.md new file mode 100644 index 00000000000..1bb16c848e2 --- /dev/null +++ b/packages/manager/.changeset/pr-11487-tech-stories-1736286047440.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update `tsconfig.json` to use `bundler` moduleResolution ([#11487](https://github.com/linode/manager/pull/11487)) diff --git a/packages/manager/cypress/tsconfig.json b/packages/manager/cypress/tsconfig.json index bedcfa42811..2bf14da000b 100644 --- a/packages/manager/cypress/tsconfig.json +++ b/packages/manager/cypress/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "moduleResolution": "node", "baseUrl": "..", "paths": { "src/*": ["./src/*"], diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index fb0a9ae3674..97e5dae32d2 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -1,7 +1,6 @@ import Factory from 'src/factories/factoryProxy'; -import type { LinodeType } from '@linode/api-v4/lib/linodes/types'; -import type { PriceType } from '@linode/api-v4/src/types'; +import type { LinodeType, PriceType } from '@linode/api-v4'; import type { PlanSelectionAvailabilityTypes, PlanWithAvailability, diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index 9ebcfefbdac..d4908d61bf9 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -43,9 +43,11 @@ import type { FileAttachment } from '../index'; import type { AttachmentError } from '../SupportTicketDetail/SupportTicketDetail'; import type { AccountLimitCustomFields } from './SupportTicketAccountLimitFields'; import type { SMTPCustomFields } from './SupportTicketSMTPFields'; -import type { CreateKubeClusterPayload } from '@linode/api-v4'; -import type { TicketSeverity } from '@linode/api-v4/lib/support'; -import type { CreateLinodeRequest } from '@linode/api-v4/src/linodes/types'; +import type { + CreateKubeClusterPayload, + CreateLinodeRequest, + TicketSeverity, +} from '@linode/api-v4'; import type { EntityForTicketDetails } from 'src/components/SupportLink/SupportLink'; interface Accumulator { diff --git a/packages/manager/src/queries/volumes/volumes.ts b/packages/manager/src/queries/volumes/volumes.ts index 7c0add54f1f..e96b6ce85af 100644 --- a/packages/manager/src/queries/volumes/volumes.ts +++ b/packages/manager/src/queries/volumes/volumes.ts @@ -26,15 +26,18 @@ import { profileQueries } from '../profile/profile'; import { getAllVolumeTypes, getAllVolumes } from './requests'; import type { + APIError, AttachVolumePayload, CloneVolumePayload, + Filter, + Params, + PriceType, ResizeVolumePayload, + ResourcePage, UpdateVolumeRequest, Volume, VolumeRequestPayload, } from '@linode/api-v4'; -import type { APIError, ResourcePage } from '@linode/api-v4/lib/types'; -import type { Filter, Params, PriceType } from '@linode/api-v4/src/types'; export const volumeQueries = createQueryKeys('volumes', { linode: (linodeId: number) => ({ diff --git a/packages/manager/tsconfig.json b/packages/manager/tsconfig.json index 25ce8001a09..652807581c7 100644 --- a/packages/manager/tsconfig.json +++ b/packages/manager/tsconfig.json @@ -7,7 +7,7 @@ /* Modules */ "baseUrl": ".", - "moduleResolution": "node", + "moduleResolution": "bundler", "paths": { "src/*": ["src/*"] }, diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json index 134d0055fe4..a175d2d5de6 100644 --- a/packages/search/tsconfig.json +++ b/packages/search/tsconfig.json @@ -10,5 +10,5 @@ "forceConsistentCasingInFileNames": true, "incremental": true }, - "include": ["src"], + "include": ["src"] } diff --git a/packages/validation/.changeset/pr-11487-tech-stories-1736286059147.md b/packages/validation/.changeset/pr-11487-tech-stories-1736286059147.md new file mode 100644 index 00000000000..e41eb5b8aec --- /dev/null +++ b/packages/validation/.changeset/pr-11487-tech-stories-1736286059147.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Tech Stories +--- + +Update `tsconfig.json` to use `bundler` moduleResolution ([#11487](https://github.com/linode/manager/pull/11487)) diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json index 6d5de643935..58df0f1dff7 100644 --- a/packages/validation/tsconfig.json +++ b/packages/validation/tsconfig.json @@ -1,20 +1,20 @@ { "compilerOptions": { "target": "esnext", - "module": "umd", + "module": "esnext", "emitDeclarationOnly": true, "declaration": true, "outDir": "./lib", "esModuleInterop": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "skipLibCheck": true, "strict": true, "baseUrl": ".", "noUnusedLocals": true, - "forceConsistentCasingInFileNames": true, "declarationMap": true, "incremental": true }, - "include": ["src"], - "exclude": ["node_modules/**/*", "**/__tests__/*"] + "include": [ + "src" + ] } From 21af1df803b5736f9cb56ac5bc900988b01a2ae4 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:49:04 -0500 Subject: [PATCH 18/42] upcoming: [M3-9084] - Handle edge cases with UDP NodeBalancer (#11515) * handle edge cases with UDP * add changesets * add one more changeset --------- Co-authored-by: Banks Nussman --- .../pr-11515-changed-1736786729001.md | 5 +++++ packages/api-v4/src/nodebalancers/types.ts | 7 ++++-- ...r-11515-upcoming-features-1736787005749.md | 5 +++++ .../NodeBalancers/NodeBalancerConfigNode.tsx | 7 +++--- .../NodeBalancerConfigurations.tsx | 6 ++--- .../src/features/NodeBalancers/utils.ts | 22 ++++++++++++++----- .../pr-11515-changed-1736786649145.md | 5 +++++ .../validation/src/nodebalancers.schema.ts | 4 ++-- 8 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11515-changed-1736786729001.md create mode 100644 packages/manager/.changeset/pr-11515-upcoming-features-1736787005749.md create mode 100644 packages/validation/.changeset/pr-11515-changed-1736786649145.md diff --git a/packages/api-v4/.changeset/pr-11515-changed-1736786729001.md b/packages/api-v4/.changeset/pr-11515-changed-1736786729001.md new file mode 100644 index 00000000000..e1e84cc7d1a --- /dev/null +++ b/packages/api-v4/.changeset/pr-11515-changed-1736786729001.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Allow `cipher_suite` to be `none` in `NodeBalancerConfig` and `CreateNodeBalancerConfig` ([#11515](https://github.com/linode/manager/pull/11515)) diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index 68f89c7ac32..ef1e1b62e4b 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -94,7 +94,10 @@ export interface NodeBalancerConfig { stickiness: Stickiness; algorithm: Algorithm; ssl_fingerprint: string; - cipher_suite: 'recommended' | 'legacy'; + /** + * Is `none` when protocol is UDP + */ + cipher_suite: 'recommended' | 'legacy' | 'none'; nodes: NodeBalancerConfigNode[]; } @@ -160,7 +163,7 @@ export interface CreateNodeBalancerConfig { * @default 80 */ udp_check_port?: number; - cipher_suite?: 'recommended' | 'legacy'; + cipher_suite?: 'recommended' | 'legacy' | 'none'; ssl_cert?: string; ssl_key?: string; } diff --git a/packages/manager/.changeset/pr-11515-upcoming-features-1736787005749.md b/packages/manager/.changeset/pr-11515-upcoming-features-1736787005749.md new file mode 100644 index 00000000000..4372f5643db --- /dev/null +++ b/packages/manager/.changeset/pr-11515-upcoming-features-1736787005749.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Improve UDP NodeBalancer support ([#11515](https://github.com/linode/manager/pull/11515)) diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index 104fb56d6d4..b4ebe485e6d 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -170,9 +170,10 @@ export const NodeBalancerConfigNode = React.memo( {!hideModeSelect && ( option.value === node.mode - )} + value={ + modeOptions.find((option) => option.value === node.mode) ?? + modeOptions.find((option) => option.value === 'accept') + } disableClearable disabled={disabled} errorText={nodesErrorMap.mode} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index 0ba3cf6b091..a108f579ee5 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -39,7 +39,7 @@ import { lensFrom } from '../NodeBalancerCreate'; import { createNewNodeBalancerConfig, createNewNodeBalancerConfigNode, - nodeForRequest, + getNodeForRequest, parseAddress, parseAddresses, transformConfigsForRequest, @@ -281,7 +281,7 @@ class NodeBalancerConfigurations extends React.Component< const config = this.state.configs[configIdx]; const node = this.state.configs[configIdx].nodes[nodeIdx]; - const nodeData = nodeForRequest(node); + const nodeData = getNodeForRequest(node, config); if (!nodeBalancerId) { return; @@ -1031,7 +1031,7 @@ class NodeBalancerConfigurations extends React.Component< const config = this.state.configs[configIdx]; const node = this.state.configs[configIdx].nodes[nodeIdx]; - const nodeData = nodeForRequest(node); + const nodeData = getNodeForRequest(node, config); if (!nodeBalancerId) { return; diff --git a/packages/manager/src/features/NodeBalancers/utils.ts b/packages/manager/src/features/NodeBalancers/utils.ts index 2cb8ea0e646..7dfd27a05d7 100644 --- a/packages/manager/src/features/NodeBalancers/utils.ts +++ b/packages/manager/src/features/NodeBalancers/utils.ts @@ -48,11 +48,17 @@ export const createNewNodeBalancerConfig = ( stickiness: SESSION_STICKINESS_DEFAULTS['http'], }); -export const nodeForRequest = (node: NodeBalancerConfigNodeFields) => ({ +export const getNodeForRequest = ( + node: NodeBalancerConfigNodeFields, + config: NodeBalancerConfigFields +) => ({ address: node.address, label: node.label, - /* Force Node creation and updates to set mode to 'accept' */ - mode: node.mode, + /** + * `mode` should not be specified for UDP because UDP does not + * support the various different modes. + */ + mode: config.protocol !== 'udp' ? node.mode : undefined, port: node.port, weight: +node.weight!, }); @@ -108,10 +114,12 @@ export const transformConfigsForRequest = ( check_timeout: !isNil(config.check_timeout) ? +config.check_timeout : undefined, - cipher_suite: config.cipher_suite || undefined, + cipher_suite: shouldIncludeCipherSuite(config) + ? config.cipher_suite + : undefined, id: undefined, nodebalancer_id: undefined, - nodes: config.nodes.map(nodeForRequest), + nodes: config.nodes.map((node) => getNodeForRequest(node, config)), nodes_status: undefined, port: config.port ? +config.port : undefined, protocol: @@ -143,6 +151,10 @@ export const transformConfigsForRequest = ( }); }; +const shouldIncludeCipherSuite = (config: NodeBalancerConfigFields) => { + return config.protocol !== 'udp'; +}; + export const shouldIncludeCheckPath = (config: NodeBalancerConfigFields) => { return ( (config.check === 'http' || config.check === 'http_body') && diff --git a/packages/validation/.changeset/pr-11515-changed-1736786649145.md b/packages/validation/.changeset/pr-11515-changed-1736786649145.md new file mode 100644 index 00000000000..58592ad97f8 --- /dev/null +++ b/packages/validation/.changeset/pr-11515-changed-1736786649145.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Allow `cipher_suite` to be `none` in NodeBalancer schemas ([#11515](https://github.com/linode/manager/pull/11515)) diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 6c93bfee5f9..84a8049d614 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -116,7 +116,7 @@ export const createNodeBalancerConfigSchema = object({ .typeError('Timeout must be a number.') .integer(), check: mixed().oneOf(['none', 'connection', 'http', 'http_body']), - cipher_suite: mixed().oneOf(['recommended', 'legacy']), + cipher_suite: string().oneOf(['recommended', 'legacy', 'none']), port: number() .integer() .required('Port is required') @@ -206,7 +206,7 @@ export const UpdateNodeBalancerConfigSchema = object({ .typeError('Timeout must be a number.') .integer(), check: mixed().oneOf(['none', 'connection', 'http', 'http_body']), - cipher_suite: mixed().oneOf(['recommended', 'legacy']), + cipher_suite: string().oneOf(['recommended', 'legacy', 'none']), port: number() .typeError('Port must be a number.') .integer() From 8a8066eed9fe5bf9c809a74bd0ba2c4c76048ac7 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Wed, 15 Jan 2025 09:46:13 -0500 Subject: [PATCH 19/42] test: [M3-8692] - Component tests for PasswordInput (#11508) * initial commit * password input tests * Added changeset: Add component tests for PasswordInput * couple other tests * test updates + address feedback @hana-akamai --- .../pr-11508-tests-1736539546177.md | 5 + .../components/password-input.spec.tsx | 176 ++++++++++++++++++ .../PasswordInput/HideShowText.stories.tsx | 3 +- .../PasswordInput/PasswordInput.stories.tsx | 3 +- .../StrengthIndicator.stories.tsx | 3 +- 5 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-11508-tests-1736539546177.md create mode 100644 packages/manager/cypress/component/components/password-input.spec.tsx diff --git a/packages/manager/.changeset/pr-11508-tests-1736539546177.md b/packages/manager/.changeset/pr-11508-tests-1736539546177.md new file mode 100644 index 00000000000..5fc77bd8126 --- /dev/null +++ b/packages/manager/.changeset/pr-11508-tests-1736539546177.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add component tests for PasswordInput ([#11508](https://github.com/linode/manager/pull/11508)) diff --git a/packages/manager/cypress/component/components/password-input.spec.tsx b/packages/manager/cypress/component/components/password-input.spec.tsx new file mode 100644 index 00000000000..1313a09052c --- /dev/null +++ b/packages/manager/cypress/component/components/password-input.spec.tsx @@ -0,0 +1,176 @@ +import * as React from 'react'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests, visualTests } from 'support/util/components'; + +import PasswordInput from 'src/components/PasswordInput/PasswordInput'; + +const fakePassword = 'this is a password'; +const props = { + label: 'Password Input', + value: fakePassword, +}; + +componentTests('PasswordInput', (mount) => { + describe('PasswordInput interactions', () => { + /** + * - Confirms password text starts hidden + * - Confirms password text can be revealed or hidden when toggling visibility icon + */ + it('can show and hide password text', () => { + mount(); + + // Password textfield starts off as 'password' type + cy.get('[type="password"]').should('be.visible'); + cy.findByTestId('VisibilityIcon').should('be.visible').click(); + cy.findByTestId('VisibilityIcon').should('not.exist'); + + // After clicking the visibility icon, textfield becomes a normal textfield + cy.get('[type="password"]').should('not.exist'); + cy.get('[type="text"]').should('be.visible'); + + // Clicking VisibilityOffIcon changes input type to password again + cy.findByTestId('VisibilityOffIcon').should('be.visible').click(); + cy.findByTestId('VisibilityOffIcon').should('not.exist'); + cy.findByTestId('VisibilityIcon').should('be.visible'); + cy.get('[type="password"]').should('be.visible'); + cy.get('[type="text"]').should('not.exist'); + }); + + /** + * - Confirms password input displays when a weak password is entered + */ + it('displays an indicator for a weak password', () => { + const TestWeakStrength = () => { + const [password, setPassword] = React.useState(''); + return ( + setPassword(e.target.value)} + value={password} + /> + ); + }; + + mount(); + + // Starts off as 'Weak' if no password entered + cy.findByText('Weak').should('be.visible'); + + cy.findByTestId('textfield-input').should('be.visible').type('weak'); + cy.findByText('Weak').should('be.visible'); + }); + + /** + * - Confirm password indicator can update when a password is entered + * - Confirms password input can display indicator for a fair password + */ + it('displays an indicator for a fair password', () => { + const TestMediumStrength = () => { + const [password, setPassword] = React.useState(''); + return ( + setPassword(e.target.value)} + value={password} + /> + ); + }; + + mount(); + + // Starts off as 'Weak' when no password entered + cy.findByText('Weak').should('be.visible'); + + cy.findByTestId('textfield-input') + .should('be.visible') + .type('fair-pass1'); + + // After typing in a fair password, the strength indicator updates + cy.findByText('Fair').should('be.visible'); + cy.findByText('Weak').should('not.exist'); + }); + + /** + * - Confirm password indicator can update when a password is entered + * - Confirms password input can display indicator for a good password + */ + it('displays an indicator for a "good" password', () => { + const TestGoodStrength = () => { + const [password, setPassword] = React.useState(''); + return ( + setPassword(e.target.value)} + value={password} + /> + ); + }; + + mount(); + + // Starts off as 'Weak' when no password entered + cy.findByText('Weak').should('be.visible'); + + cy.findByTestId('textfield-input') + .should('be.visible') + .type('str0ng!!-password1!!'); + + // After typing in a strong password, the strength indicator updates + cy.findByText('Good').should('be.visible'); + cy.findByText('Weak').should('not.exist'); + }); + }); + + visualTests((mount) => { + describe('Accessibility checks', () => { + it('passes aXe check when password input is visible', () => { + mount(); + cy.findByTestId('VisibilityIcon').should('be.visible').click(); + + checkComponentA11y(); + }); + + it('passes aXe check when password input is not visible', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check for a weak password', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check for a fair password', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check for a "good" password', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when password input is designated as required', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when strength value is hidden', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when strength label is shown', () => { + mount(); + + checkComponentA11y(); + }); + }); + }); +}); diff --git a/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx b/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx index b2561a58866..5608aa6bd4e 100644 --- a/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx +++ b/packages/manager/src/components/PasswordInput/HideShowText.stories.tsx @@ -1,9 +1,10 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Meta, StoryObj } from '@storybook/react'; import React, { useState } from 'react'; import { HideShowText } from './HideShowText'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: HideShowText, title: 'Components/Input/Hide Show Text', diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx index 67a76b58e8e..12ec8a4e7be 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.stories.tsx @@ -1,9 +1,10 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Meta, StoryObj } from '@storybook/react'; import React, { useState } from 'react'; import PasswordInput from './PasswordInput'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: PasswordInput, title: 'Components/Input/Password Input', diff --git a/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx b/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx index 227b36af4e1..bd41bb63e86 100644 --- a/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx +++ b/packages/manager/src/components/PasswordInput/StrengthIndicator.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { StrengthIndicator } from './StrengthIndicator'; +import type { Meta, StoryObj } from '@storybook/react'; + const meta: Meta = { component: StrengthIndicator, title: 'Components/Strength Indicator', From 514cb974e02d4dbd7261edb49fff2b04f37a3f4f Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Wed, 15 Jan 2025 12:33:44 -0500 Subject: [PATCH 20/42] deps: [M3-9082, M3-9083] - Dependabot Fixes (#11510) * deps: [M3-9082] - Dependabot Fixes * Added changeset: Dependabot security fixes --------- Co-authored-by: Jaalah Ramos --- package.json | 3 +- .../pr-11510-tech-stories-1736865158796.md | 5 + packages/manager/package.json | 30 +- yarn.lock | 1156 +++++------------ 4 files changed, 358 insertions(+), 836 deletions(-) create mode 100644 packages/manager/.changeset/pr-11510-tech-stories-1736865158796.md diff --git a/package.json b/package.json index 46ba965031f..6b603d00873 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "node-fetch": "^2.6.7", "yaml": "^2.3.0", "semver": "^7.5.2", - "cookie": "^0.7.0" + "cookie": "^0.7.0", + "nanoid": "^3.3.8" }, "workspaces": { "packages": [ diff --git a/packages/manager/.changeset/pr-11510-tech-stories-1736865158796.md b/packages/manager/.changeset/pr-11510-tech-stories-1736865158796.md new file mode 100644 index 00000000000..4fcf12b67a6 --- /dev/null +++ b/packages/manager/.changeset/pr-11510-tech-stories-1736865158796.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Dependabot security fixes ([#11510](https://github.com/linode/manager/pull/11510)) diff --git a/packages/manager/package.json b/packages/manager/package.json index b84877cb298..866c03724f1 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -119,20 +119,20 @@ }, "devDependencies": { "@linode/eslint-plugin-cloud-manager": "^0.0.5", - "@storybook/addon-a11y": "^8.3.0", - "@storybook/addon-actions": "^8.3.0", - "@storybook/addon-controls": "^8.3.0", - "@storybook/addon-docs": "^8.3.0", - "@storybook/addon-mdx-gfm": "^8.3.0", - "@storybook/addon-measure": "^8.3.0", - "@storybook/addon-storysource": "^8.3.0", - "@storybook/addon-viewport": "^8.3.0", - "@storybook/blocks": "^8.3.0", - "@storybook/manager-api": "^8.3.0", - "@storybook/preview-api": "^8.3.0", - "@storybook/react": "^8.3.0", - "@storybook/react-vite": "^8.3.0", - "@storybook/theming": "^8.3.0", + "@storybook/addon-a11y": "^8.4.7", + "@storybook/addon-actions": "^8.4.7", + "@storybook/addon-controls": "^8.4.7", + "@storybook/addon-docs": "^8.4.7", + "@storybook/addon-mdx-gfm": "^8.4.7", + "@storybook/addon-measure": "^8.4.7", + "@storybook/addon-storysource": "^8.4.7", + "@storybook/addon-viewport": "^8.4.7", + "@storybook/blocks": "^8.4.7", + "@storybook/manager-api": "^8.4.7", + "@storybook/preview-api": "^8.4.7", + "@storybook/react": "^8.4.7", + "@storybook/react-vite": "^8.4.7", + "@storybook/theming": "^8.4.7", "@swc/core": "^1.3.1", "@testing-library/cypress": "^10.0.2", "@testing-library/dom": "^10.1.0", @@ -203,7 +203,7 @@ "msw": "^2.2.3", "prettier": "~2.2.1", "redux-mock-store": "^1.5.3", - "storybook": "^8.3.0", + "storybook": "^8.4.7", "storybook-dark-mode": "4.0.1", "vite": "^5.4.6", "vite-plugin-svgr": "^3.2.0" diff --git a/yarn.lock b/yarn.lock index d059e3f4e02..2a2b9a744d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -307,11 +307,6 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" -"@base2/pretty-print-object@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" - integrity sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA== - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -656,6 +651,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" integrity sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ== +"@esbuild/aix-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" + integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== + "@esbuild/android-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" @@ -666,6 +666,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz#58565291a1fe548638adb9c584237449e5e14018" integrity sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw== +"@esbuild/android-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" + integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== + "@esbuild/android-arm@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" @@ -676,6 +681,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.1.tgz#5eb8c652d4c82a2421e3395b808e6d9c42c862ee" integrity sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ== +"@esbuild/android-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" + integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== + "@esbuild/android-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" @@ -686,6 +696,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.1.tgz#ae19d665d2f06f0f48a6ac9a224b3f672e65d517" integrity sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg== +"@esbuild/android-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" + integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== + "@esbuild/darwin-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" @@ -696,6 +711,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz#05b17f91a87e557b468a9c75e9d85ab10c121b16" integrity sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q== +"@esbuild/darwin-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" + integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== + "@esbuild/darwin-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" @@ -706,6 +726,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz#c58353b982f4e04f0d022284b8ba2733f5ff0931" integrity sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw== +"@esbuild/darwin-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" + integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== + "@esbuild/freebsd-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" @@ -716,6 +741,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz#f9220dc65f80f03635e1ef96cfad5da1f446f3bc" integrity sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA== +"@esbuild/freebsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" + integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== + "@esbuild/freebsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" @@ -726,6 +756,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz#69bd8511fa013b59f0226d1609ac43f7ce489730" integrity sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g== +"@esbuild/freebsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" + integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== + "@esbuild/linux-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" @@ -736,6 +771,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz#8050af6d51ddb388c75653ef9871f5ccd8f12383" integrity sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g== +"@esbuild/linux-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" + integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== + "@esbuild/linux-arm@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" @@ -746,6 +786,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz#ecaabd1c23b701070484990db9a82f382f99e771" integrity sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ== +"@esbuild/linux-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" + integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== + "@esbuild/linux-ia32@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" @@ -756,6 +801,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz#3ed2273214178109741c09bd0687098a0243b333" integrity sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ== +"@esbuild/linux-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" + integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== + "@esbuild/linux-loong64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" @@ -766,6 +816,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz#a0fdf440b5485c81b0fbb316b08933d217f5d3ac" integrity sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw== +"@esbuild/linux-loong64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" + integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== + "@esbuild/linux-mips64el@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" @@ -776,6 +831,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz#e11a2806346db8375b18f5e104c5a9d4e81807f6" integrity sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q== +"@esbuild/linux-mips64el@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" + integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== + "@esbuild/linux-ppc64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" @@ -786,6 +846,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz#06a2744c5eaf562b1a90937855b4d6cf7c75ec96" integrity sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw== +"@esbuild/linux-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" + integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== + "@esbuild/linux-riscv64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" @@ -796,6 +861,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz#65b46a2892fc0d1af4ba342af3fe0fa4a8fe08e7" integrity sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA== +"@esbuild/linux-riscv64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" + integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== + "@esbuild/linux-s390x@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" @@ -806,6 +876,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz#e71ea18c70c3f604e241d16e4e5ab193a9785d6f" integrity sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw== +"@esbuild/linux-s390x@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" + integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== + "@esbuild/linux-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" @@ -816,6 +891,16 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz#d47f97391e80690d4dfe811a2e7d6927ad9eed24" integrity sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ== +"@esbuild/linux-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" + integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== + +"@esbuild/netbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" + integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== + "@esbuild/netbsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" @@ -826,11 +911,21 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz#44e743c9778d57a8ace4b72f3c6b839a3b74a653" integrity sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA== +"@esbuild/netbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" + integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== + "@esbuild/openbsd-arm64@0.23.1": version "0.23.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" integrity sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q== +"@esbuild/openbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" + integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== + "@esbuild/openbsd-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" @@ -841,6 +936,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz#2e58ae511bacf67d19f9f2dcd9e8c5a93f00c273" integrity sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA== +"@esbuild/openbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" + integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== + "@esbuild/sunos-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" @@ -851,6 +951,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz#adb022b959d18d3389ac70769cef5a03d3abd403" integrity sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA== +"@esbuild/sunos-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" + integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== + "@esbuild/win32-arm64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" @@ -861,6 +966,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz#84906f50c212b72ec360f48461d43202f4c8b9a2" integrity sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A== +"@esbuild/win32-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" + integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== + "@esbuild/win32-ia32@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" @@ -871,6 +981,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz#5e3eacc515820ff729e90d0cb463183128e82fac" integrity sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ== +"@esbuild/win32-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" + integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== + "@esbuild/win32-x64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" @@ -881,6 +996,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz#81fd50d11e2c32b2d6241470e3185b70c7b30699" integrity sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg== +"@esbuild/win32-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" + integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1178,13 +1298,11 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@joshwooding/vite-plugin-react-docgen-typescript@0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.3.0.tgz#67599fca260c2eafdaf234a944f9d471e6d53b08" - integrity sha512-2D6y7fNvFmsLmRt6UCOFJPvFoPMJGT0Uh1Wg0RaigUp7kdQPs6yYn8Dmx6GZkOH/NW0yMTwRz/p0SRMMRo50vA== +"@joshwooding/vite-plugin-react-docgen-typescript@0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.4.2.tgz#c2591d2d7b02160341672d6bf3cc248dd60f2530" + integrity sha512-feQ+ntr+8hbVudnsTUapiMN9q8T90XA1d5jn9QzY09sNoj4iD9wi0PY1vsBFTda4ZjEaxRK9S81oarR2nj7TFQ== dependencies: - glob "^7.2.0" - glob-promise "^4.2.0" magic-string "^0.27.0" react-docgen-typescript "^2.2.2" @@ -1694,18 +1812,18 @@ dependencies: "@sentry/types" "7.119.1" -"@storybook/addon-a11y@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-8.3.3.tgz#1ab0db4b559f6ba6bb33c928c634b15d21bd5a72" - integrity sha512-TiCbNfKJOBD2b8mMqHOii8ntdt0V4+ifAgzmGku+F1hdf2EhEw1nL6CHpvnx/GBXoGeK4mrPJIKKoPNp+zz0dw== +"@storybook/addon-a11y@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-a11y/-/addon-a11y-8.4.7.tgz#0073090d8d4e0748249317a292ac27dc2c2b9ef2" + integrity sha512-GpUvXp6n25U1ZSv+hmDC+05BEqxWdlWjQTb/GaboRXZQeMBlze6zckpVb66spjmmtQAIISo0eZxX1+mGcVR7lA== dependencies: - "@storybook/addon-highlight" "8.3.3" + "@storybook/addon-highlight" "8.4.7" axe-core "^4.2.0" -"@storybook/addon-actions@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-8.3.3.tgz#6b3289071fa887eb08aa858aa64a87e93f0bb440" - integrity sha512-cbpksmld7iADwDGXgojZ4r8LGI3YA3NP68duAHg2n1dtnx1oUaFK5wd6dbNuz7GdjyhIOIy3OKU1dAuylYNGOQ== +"@storybook/addon-actions@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-8.4.7.tgz#210c6bb5a7e17c3664c300b4b69b6243ec34b9cd" + integrity sha512-mjtD5JxcPuW74T6h7nqMxWTvDneFtokg88p6kQ5OnC1M259iAXb//yiSZgu/quunMHPCXSiqn4FNOSgASTSbsA== dependencies: "@storybook/global" "^5.0.0" "@types/uuid" "^9.0.1" @@ -1713,109 +1831,91 @@ polished "^4.2.2" uuid "^9.0.0" -"@storybook/addon-controls@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-8.3.3.tgz#bad8729f03897f9df0909a11e9181a9d88eb274d" - integrity sha512-78xRtVpY7eX/Lti00JLgwYCBRB6ZcvzY3SWk0uQjEqcTnQGoQkVg2L7oWFDlDoA1LBY18P5ei2vu8MYT9GXU4g== +"@storybook/addon-controls@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-8.4.7.tgz#0c2ace0c7056248577f08f90471f29e861b485be" + integrity sha512-377uo5IsJgXLnQLJixa47+11V+7Wn9KcDEw+96aGCBCfLbWNH8S08tJHHnSu+jXg9zoqCAC23MetntVp6LetHA== dependencies: "@storybook/global" "^5.0.0" dequal "^2.0.2" - lodash "^4.17.21" ts-dedent "^2.0.0" -"@storybook/addon-docs@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.3.3.tgz#77869084cbbfaec9d3bbcdf18413de7f627ce81d" - integrity sha512-REUandqq1RnMNOhsocRwx5q2fdlBAYPTDFlKASYfEn4Ln5NgbQRGxOAWl7yXAAFzbDmUDU7K20hkauecF0tyMw== +"@storybook/addon-docs@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.4.7.tgz#556515da1049f97023427301e11ecb52d0b9dbe7" + integrity sha512-NwWaiTDT5puCBSUOVuf6ME7Zsbwz7Y79WF5tMZBx/sLQ60vpmJVQsap6NSjvK1Ravhc21EsIXqemAcBjAWu80w== dependencies: "@mdx-js/react" "^3.0.0" - "@storybook/blocks" "8.3.3" - "@storybook/csf-plugin" "8.3.3" - "@storybook/global" "^5.0.0" - "@storybook/react-dom-shim" "8.3.3" - "@types/react" "^16.8.0 || ^17.0.0 || ^18.0.0" - fs-extra "^11.1.0" + "@storybook/blocks" "8.4.7" + "@storybook/csf-plugin" "8.4.7" + "@storybook/react-dom-shim" "8.4.7" react "^16.8.0 || ^17.0.0 || ^18.0.0" react-dom "^16.8.0 || ^17.0.0 || ^18.0.0" - rehype-external-links "^3.0.0" - rehype-slug "^6.0.0" ts-dedent "^2.0.0" -"@storybook/addon-highlight@8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-highlight/-/addon-highlight-8.3.3.tgz#2e1d96bdd8049af7343300cbb43adb4480f3ed7d" - integrity sha512-MB084xJM66rLU+iFFk34kjLUiAWzDiy6Kz4uZRa1CnNqEK0sdI8HaoQGgOxTIa2xgJor05/8/mlYlMkP/0INsQ== +"@storybook/addon-highlight@8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-highlight/-/addon-highlight-8.4.7.tgz#06b9752977e38884007e9446f9a2b0c04c873229" + integrity sha512-whQIDBd3PfVwcUCrRXvCUHWClXe9mQ7XkTPCdPo4B/tZ6Z9c6zD8JUHT76ddyHivixFLowMnA8PxMU6kCMAiNw== dependencies: "@storybook/global" "^5.0.0" -"@storybook/addon-mdx-gfm@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-mdx-gfm/-/addon-mdx-gfm-8.3.3.tgz#604930985453c4a4bdc3ccbab26ff043a54dc18d" - integrity sha512-jdwVXoBSEdmuw8L4MxUeJ/qIInADfCwdtShnfTQIJBBRucOl8ykgfTKKNjllT79TFiK0gsWoiZmE05P4wuBofw== +"@storybook/addon-mdx-gfm@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-mdx-gfm/-/addon-mdx-gfm-8.4.7.tgz#6c382ab67a82aad8be1f8539e1a5b74038dcb910" + integrity sha512-RLenpDmY0HZLqh8T6ZamSeUaLkFFJGMivIs5T3IhAo+BecYA1gWzD+T5er/k8AH8HyYJUtxt/IMCx5UrGnUr7g== dependencies: remark-gfm "^4.0.0" ts-dedent "^2.0.0" -"@storybook/addon-measure@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-8.3.3.tgz#9ff6e749ab6c0661252a195ec355f6a6c5bace07" - integrity sha512-R20Z83gnxDRrocES344dw1Of/zDhe3XHSM6TLq80UQTJ9PhnMI+wYHQlK9DsdP3KiRkI+pQA6GCOp0s2ZRy5dg== +"@storybook/addon-measure@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-8.4.7.tgz#9d556ba34b57c13ad8d00bd953b27ec405a64d23" + integrity sha512-QfvqYWDSI5F68mKvafEmZic3SMiK7zZM8VA0kTXx55hF/+vx61Mm0HccApUT96xCXIgmwQwDvn9gS4TkX81Dmw== dependencies: "@storybook/global" "^5.0.0" tiny-invariant "^1.3.1" -"@storybook/addon-storysource@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-storysource/-/addon-storysource-8.3.3.tgz#de1c0db04a927cc6af91aac9b7590cf30058b627" - integrity sha512-yPYQH9NepSNxoSsV9E7OV3/EVFrbU/r2B3E5WP/mCfqTXPg/5noce7iRi+rWqcVM1tsN1qPnSjfQQc7noF0h0Q== +"@storybook/addon-storysource@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-storysource/-/addon-storysource-8.4.7.tgz#4e07961307752662c163cc2f713e4436dd4c69d0" + integrity sha512-ckMSiVf+8V3IVN3lTdzCdToXVoGhZ57pwMv0OpkdVIEn6sqHFHwHrOYiXpF3SXTicwayjylcL1JXTGoBFFDVOQ== dependencies: - "@storybook/source-loader" "8.3.3" + "@storybook/source-loader" "8.4.7" estraverse "^5.2.0" tiny-invariant "^1.3.1" -"@storybook/addon-viewport@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-8.3.3.tgz#53315cb90e013fdee514df86e415747f4be3126d" - integrity sha512-2S+UpbKAL+z1ppzUCkixjaem2UDMkfmm/kyJ1wm3A/ofGLYi4fjMSKNRckk+7NdolXGQJjBo0RcaotUTxFIFwQ== +"@storybook/addon-viewport@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-8.4.7.tgz#e65c53608f52149c06347b395487960605fc4805" + integrity sha512-hvczh/jjuXXcOogih09a663sRDDSATXwbE866al1DXgbDFraYD/LxX/QDb38W9hdjU9+Qhx8VFIcNWoMQns5HQ== dependencies: memoizerific "^1.11.3" -"@storybook/blocks@8.3.3", "@storybook/blocks@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.3.3.tgz#a123746b472488d3c6ccc08b1fe831474ec992b0" - integrity sha512-8Vsvxqstop3xfbsx3Dn1nEjyxvQUcOYd8vpxyp2YumxYO8FlXIRuYL6HAkYbcX8JexsKvCZYxor52D2vUGIKZg== +"@storybook/blocks@8.4.7", "@storybook/blocks@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.4.7.tgz#ee17f59dd52d11c97c39b0f6b03957085a80ad95" + integrity sha512-+QH7+JwXXXIyP3fRCxz/7E2VZepAanXJM7G8nbR3wWsqWgrRp4Wra6MvybxAYCxU7aNfJX5c+RW84SNikFpcIA== dependencies: "@storybook/csf" "^0.1.11" - "@storybook/global" "^5.0.0" - "@storybook/icons" "^1.2.10" - "@types/lodash" "^4.14.167" - color-convert "^2.0.1" - dequal "^2.0.2" - lodash "^4.17.21" - markdown-to-jsx "^7.4.5" - memoizerific "^1.11.3" - polished "^4.2.2" - react-colorful "^5.1.2" - telejson "^7.2.0" + "@storybook/icons" "^1.2.12" ts-dedent "^2.0.0" - util-deprecate "^1.0.2" -"@storybook/builder-vite@8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/builder-vite/-/builder-vite-8.3.3.tgz#40bc458ac735c0c0dac29d9bded6f4dd05bb9104" - integrity sha512-3yTXCLaB6bzhoPH3PqtacKkcaC1uV4L+IHTf1Zypx1NO1pLZHyhYf0T7dIOxTh2JZfqu1Pm9hTvOmWfR12m+9w== +"@storybook/builder-vite@8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/builder-vite/-/builder-vite-8.4.7.tgz#3d6d542fa1f46fce5ee7a159dc8491cb4421254d" + integrity sha512-LovyXG5VM0w7CovI/k56ZZyWCveQFVDl0m7WwetpmMh2mmFJ+uPQ35BBsgTvTfc8RHi+9Q3F58qP1MQSByXi9g== dependencies: - "@storybook/csf-plugin" "8.3.3" - "@types/find-cache-dir" "^3.2.1" + "@storybook/csf-plugin" "8.4.7" browser-assert "^1.2.1" - es-module-lexer "^1.5.0" - express "^4.19.2" - find-cache-dir "^3.0.0" - fs-extra "^11.1.0" - magic-string "^0.30.0" ts-dedent "^2.0.0" -"@storybook/components@^8.0.0", "@storybook/components@^8.3.3": +"@storybook/components@8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.4.7.tgz#09eeffa07aa672ad3966ca1764a43003731b1d30" + integrity sha512-uyJIcoyeMWKAvjrG9tJBUCKxr2WZk+PomgrgrUwejkIfXMO76i6jw9BwLa0NZjYdlthDv30r9FfbYZyeNPmF0g== + +"@storybook/components@^8.0.0": version "8.3.3" resolved "https://registry.yarnpkg.com/@storybook/components/-/components-8.3.3.tgz#4b3ac4eedba3bca0884782916c4f6f1e7003b741" integrity sha512-i2JYtesFGkdu+Hwuj+o9fLuO3yo+LPT1/8o5xBVYtEqsgDtEAyuRUWjSz8d8NPtzloGPOv5kvR6MokWDfbeMfw== @@ -1825,18 +1925,16 @@ resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-8.3.3.tgz#0b7cb3b737335a5d4091108a01352720e0e1f965" integrity sha512-YL+gBuCS81qktzTkvw0MXUJW0bYAXfRzMoiLfDBTrEKZfcJOB4JAlMGmvRRar0+jygK3icD42Rl5BwWoZY6KFQ== -"@storybook/core@8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.3.3.tgz#657ce39312ceec5ba03382fe4d4d83ca396bb9ab" - integrity sha512-pmf2bP3fzh45e56gqOuBT8sDX05hGdUKIZ/hcI84d5xmd6MeHiPW8th2v946wCHcxHzxib2/UU9vQUh+mB4VNw== +"@storybook/core@8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/core/-/core-8.4.7.tgz#af9cbb3f26f0b6c98c679a134ce776c202570d66" + integrity sha512-7Z8Z0A+1YnhrrSXoKKwFFI4gnsLbWzr8fnDCU6+6HlDukFYh8GHRcZ9zKfqmy6U3hw2h8H5DrHsxWfyaYUUOoA== dependencies: "@storybook/csf" "^0.1.11" - "@types/express" "^4.17.21" better-opn "^3.0.2" browser-assert "^1.2.1" - esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0" + esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0" esbuild-register "^3.5.0" - express "^4.19.2" jsdoc-type-pratt-parser "^4.0.0" process "^0.11.10" recast "^0.23.5" @@ -1844,10 +1942,10 @@ util "^0.12.5" ws "^8.2.3" -"@storybook/csf-plugin@8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.3.3.tgz#8112d98222f9b3650d5924673d30dfd9bb55457b" - integrity sha512-7AD7ojpXr3THqpTcEI4K7oKUfSwt1hummgL/cASuQvEPOwAZCVZl2gpGtKxcXhtJXTkn3GMCAvlYMoe7O/1YWw== +"@storybook/csf-plugin@8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.4.7.tgz#0117c872b05bf033eec089ab0224e0fab01da810" + integrity sha512-Fgogplu4HImgC+AYDcdGm1rmL6OR1rVdNX1Be9C/NEXwOCpbbBwi0BxTf/2ZxHRk9fCeaPEcOdP5S8QHfltc1g== dependencies: unplugin "^1.3.1" @@ -1863,78 +1961,79 @@ resolved "https://registry.yarnpkg.com/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== -"@storybook/icons@^1.2.10", "@storybook/icons@^1.2.5": +"@storybook/icons@^1.2.12": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-1.3.0.tgz#a5c1460fb15a7260e0b638ab86163f7347a0061e" + integrity sha512-Nz/UzeYQdUZUhacrPyfkiiysSjydyjgg/p0P9HxB4p/WaJUUjMAcaoaLgy3EXx61zZJ3iD36WPuDkZs5QYrA0A== + +"@storybook/icons@^1.2.5": version "1.2.12" resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-1.2.12.tgz#3e4c939113b67df7ab17b78f805dbb57f4acf0db" integrity sha512-UxgyK5W3/UV4VrI3dl6ajGfHM4aOqMAkFLWe2KibeQudLf6NJpDrDMSHwZj+3iKC4jFU7dkKbbtH2h/al4sW3Q== -"@storybook/manager-api@^8.0.0", "@storybook/manager-api@^8.3.0", "@storybook/manager-api@^8.3.3": +"@storybook/manager-api@8.4.7", "@storybook/manager-api@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-8.4.7.tgz#4e13debf645c9300d7d6d49195e720d0c7ecd261" + integrity sha512-ELqemTviCxAsZ5tqUz39sDmQkvhVAvAgiplYy9Uf15kO0SP2+HKsCMzlrm2ue2FfkUNyqbDayCPPCB0Cdn/mpQ== + +"@storybook/manager-api@^8.0.0": version "8.3.3" resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-8.3.3.tgz#5518cc761264c9972732fcd9e025a7bc2fee7297" integrity sha512-Na4U+McOeVUJAR6qzJfQ6y2Qt0kUgEDUriNoAn+curpoKPTmIaZ79RAXBzIqBl31VyQKknKpZbozoRGf861YaQ== -"@storybook/preview-api@^8.3.0", "@storybook/preview-api@^8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.3.3.tgz#9f625a2d5e647137c5df7e419eda59e98f88cd44" - integrity sha512-GP2QlaF3BBQGAyo248N7549YkTQjCentsc1hUvqPnFWU4xfjkejbnFk8yLaIw0VbYbL7jfd7npBtjZ+6AnphMQ== +"@storybook/preview-api@8.4.7", "@storybook/preview-api@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-8.4.7.tgz#85e01a97f4182b974581765d725f6c7a7d190013" + integrity sha512-0QVQwHw+OyZGHAJEXo6Knx+6/4er7n2rTDE5RYJ9F2E2Lg42E19pfdLlq2Jhoods2Xrclo3wj6GWR//Ahi39Eg== -"@storybook/react-dom-shim@8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.3.3.tgz#0a23588f507c5c69b1153e43f16c37dbf38b82f1" - integrity sha512-0dPC9K7+K5+X/bt3GwYmh+pCpisUyKVjWsI+PkzqGnWqaXFakzFakjswowIAIO1rf7wYZR591x3ehUAyL2bJiQ== +"@storybook/react-dom-shim@8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-8.4.7.tgz#f0dd5bbf2fc185def72d9d08a11c8de22f152c2a" + integrity sha512-6bkG2jvKTmWrmVzCgwpTxwIugd7Lu+2btsLAqhQSzDyIj2/uhMNp8xIMr/NBDtLgq3nomt9gefNa9xxLwk/OMg== -"@storybook/react-vite@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-8.3.3.tgz#3ce3d5e25b302ba256c74e1e7871f38eba23cdc6" - integrity sha512-vzOqVaA/rv+X5J17eWKxdZztMKEKfsCSP8pNNmrqXWxK3pSlW0fAPxtn1kw3UNxGtAv71pcqvaCUtTJKqI1PYA== +"@storybook/react-vite@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-8.4.7.tgz#1a755596d65551c77850361da76df47027687664" + integrity sha512-iiY9iLdMXhDnilCEVxU6vQsN72pW3miaf0WSenOZRyZv3HdbpgOxI0qapOS0KCyRUnX9vTlmrSPTMchY4cAeOg== dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript" "0.3.0" + "@joshwooding/vite-plugin-react-docgen-typescript" "0.4.2" "@rollup/pluginutils" "^5.0.2" - "@storybook/builder-vite" "8.3.3" - "@storybook/react" "8.3.3" + "@storybook/builder-vite" "8.4.7" + "@storybook/react" "8.4.7" find-up "^5.0.0" magic-string "^0.30.0" react-docgen "^7.0.0" resolve "^1.22.8" tsconfig-paths "^4.2.0" -"@storybook/react@8.3.3", "@storybook/react@^8.3.0": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/react/-/react-8.3.3.tgz#87d16b3a22f4ace86747f6a382f506a7550a31dc" - integrity sha512-fHOW/mNqI+sZWttGOE32Q+rAIbN7/Oib091cmE8usOM0z0vPNpywUBtqC2cCQH39vp19bhTsQaSsTcoBSweAHw== +"@storybook/react@8.4.7", "@storybook/react@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-8.4.7.tgz#e2cf62b3c1d8e4bfe5eff82ced07ec473d4e4fd1" + integrity sha512-nQ0/7i2DkaCb7dy0NaT95llRVNYWQiPIVuhNfjr1mVhEP7XD090p0g7eqUmsx8vfdHh2BzWEo6CoBFRd3+EXxw== dependencies: - "@storybook/components" "^8.3.3" + "@storybook/components" "8.4.7" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "^8.3.3" - "@storybook/preview-api" "^8.3.3" - "@storybook/react-dom-shim" "8.3.3" - "@storybook/theming" "^8.3.3" - "@types/escodegen" "^0.0.6" - "@types/estree" "^0.0.51" - "@types/node" "^22.0.0" - acorn "^7.4.1" - acorn-jsx "^5.3.1" - acorn-walk "^7.2.0" - escodegen "^2.1.0" - html-tags "^3.1.0" - prop-types "^15.7.2" - react-element-to-jsx-string "^15.0.0" - semver "^7.3.7" - ts-dedent "^2.0.0" - type-fest "~2.19" - util-deprecate "^1.0.2" + "@storybook/manager-api" "8.4.7" + "@storybook/preview-api" "8.4.7" + "@storybook/react-dom-shim" "8.4.7" + "@storybook/theming" "8.4.7" -"@storybook/source-loader@8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-8.3.3.tgz#f97db24267f6dc66ff662fa2c6f13362135be040" - integrity sha512-NeP7l53mvnnfwi+91vtRaibZer+UJi6gkoaGRCpphL3L+3qVIXN3p41uXhAy+TahdFI2dbrWvLSNgtsvdXVaFg== +"@storybook/source-loader@8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/source-loader/-/source-loader-8.4.7.tgz#c41f213c8e6440a310d5e616353b3266d2b56b56" + integrity sha512-DrsYGGfNbbqlMzkhbLoNyNqrPa4QIkZ6O7FJ8Z/8jWb0cerQH2N6JW6k12ZnXgs8dO2Z33+iSEDIV8odh0E0PA== dependencies: "@storybook/csf" "^0.1.11" + es-toolkit "^1.22.0" estraverse "^5.2.0" - lodash "^4.17.21" prettier "^3.1.1" -"@storybook/theming@^8.0.0", "@storybook/theming@^8.3.0", "@storybook/theming@^8.3.3": +"@storybook/theming@8.4.7", "@storybook/theming@^8.4.7": + version "8.4.7" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5" + integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw== + +"@storybook/theming@^8.0.0": version "8.3.3" resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.3.3.tgz#38f2fb24e719f7a97c359a84c93be86ca2c9a20e" integrity sha512-gWJKetI6XJQgkrvvry4ez10+jLaGNCQKi5ygRPM9N+qrjA3BB8F2LCuFUTBuisa4l64TILDNjfwP/YTWV5+u5A== @@ -2241,14 +2340,6 @@ dependencies: "@babel/types" "^7.20.7" -"@types/body-parser@*": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" - integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== - dependencies: - "@types/connect" "*" - "@types/node" "*" - "@types/braintree-web@^3.75.23": version "3.96.14" resolved "https://registry.yarnpkg.com/@types/braintree-web/-/braintree-web-3.96.14.tgz#7303a5439bbc4a3a4b497bbac4bec77921e97cab" @@ -2276,13 +2367,6 @@ dependencies: moment "^2.10.2" -"@types/connect@*": - version "3.4.38" - resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" - integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== - dependencies: - "@types/node" "*" - "@types/cookie@^0.6.0": version "0.6.0" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" @@ -2363,71 +2447,21 @@ dependencies: "@types/trusted-types" "*" -"@types/escodegen@^0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@types/escodegen/-/escodegen-0.0.6.tgz#5230a9ce796e042cda6f086dbf19f22ea330659c" - integrity sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig== - "@types/estree@1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== -"@types/estree@^0.0.51": - version "0.0.51" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" - integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== - "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== -"@types/express-serve-static-core@^4.17.33": - version "4.19.6" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz#e01324c2a024ff367d92c66f48553ced0ab50267" - integrity sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A== - dependencies: - "@types/node" "*" - "@types/qs" "*" - "@types/range-parser" "*" - "@types/send" "*" - -"@types/express@^4.17.21": - version "4.17.21" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" - integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== - dependencies: - "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.33" - "@types/qs" "*" - "@types/serve-static" "*" - -"@types/find-cache-dir@^3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz#7b959a4b9643a1e6a1a5fe49032693cc36773501" - integrity sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw== - -"@types/glob@^7.1.3": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" - integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== - dependencies: - "@types/minimatch" "*" - "@types/node" "*" - "@types/googlepay@*": version "0.7.6" resolved "https://registry.yarnpkg.com/@types/googlepay/-/googlepay-0.7.6.tgz#ba444ad8b2945e70f873673b8f5371745b8cfe37" integrity sha512-5003wG+qvf4Ktf1hC9IJuRakNzQov00+Xf09pAWGJLpdOjUrq0SSLCpXX7pwSeTG9r5hrdzq1iFyZcW7WVyr4g== -"@types/hast@^3.0.0": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" - integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== - dependencies: - "@types/unist" "*" - "@types/he@^1.1.0": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.3.tgz#c33ca3096f30cbd5d68d78211572de3f9adff75a" @@ -2458,11 +2492,6 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" -"@types/http-errors@*": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" - integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== - "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -2478,11 +2507,6 @@ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== -"@types/lodash@^4.14.167": - version "4.17.9" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.9.tgz#0dc4902c229f6b8e2ac5456522104d7b1a230290" - integrity sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w== - "@types/luxon@3.4.2": version "3.4.2" resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" @@ -2520,16 +2544,6 @@ resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd" integrity sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw== -"@types/mime@^1": - version "1.3.5" - resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" - integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== - -"@types/minimatch@*": - version "5.1.2" - resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" - integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== - "@types/mocha@^10.0.2": version "10.0.8" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.8.tgz#a7eff5816e070c3b4d803f1d3cd780c4e42934a1" @@ -2547,7 +2561,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@^22.0.0", "@types/node@^22.5.5": +"@types/node@*", "@types/node@^22.5.5": version "22.7.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.1.tgz#c6a2628c8a68511ab7b68f3be7c9b38716bdf04f" integrity sha512-adOMRLVmleuWs/5V/w5/l7o0chDK/az+5ncCsIapTKogsu/3MVWvSgP58qVTXi5IwpfGt8pMobNq9rOWtJyu5Q== @@ -2583,11 +2597,6 @@ dependencies: "@types/react" "*" -"@types/qs@*": - version "6.9.16" - resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.16.tgz#52bba125a07c0482d26747d5d4947a64daf8f794" - integrity sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A== - "@types/raf@^3.4.0": version "3.4.3" resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.3.tgz#85f1d1d17569b28b8db45e16e996407a56b0ab04" @@ -2598,11 +2607,6 @@ resolved "https://registry.yarnpkg.com/@types/ramda/-/ramda-0.25.16.tgz#1d4eeb78d3247d1ceef971b458dd8469646cd1b4" integrity sha512-jNxaEg+kSJ58iaM9bBawJugDxexXVPnLU245yEI1p2BTcfR5pcgM6mpsyBhRRo2ozyfJUvTmasL2Ft+C6BNkVQ== -"@types/range-parser@*": - version "1.2.7" - resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" - integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== - "@types/react-beautiful-dnd@^13.0.0": version "13.1.8" resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz#f52d3ea07e1e19159d6c3c4a48c8da3d855e60b4" @@ -2675,7 +2679,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.55": +"@types/react@*", "@types/react@^18.2.55": version "18.3.9" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.9.tgz#2cdf5f425ec8a133d67e9e3673909738b783db20" integrity sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ== @@ -2708,23 +2712,6 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== -"@types/send@*": - version "0.17.4" - resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" - integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== - dependencies: - "@types/mime" "^1" - "@types/node" "*" - -"@types/serve-static@*": - version "1.15.7" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.7.tgz#22174bbd74fb97fe303109738e9b5c2f3064f714" - integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== - dependencies: - "@types/http-errors" "*" - "@types/node" "*" - "@types/send" "*" - "@types/sinonjs__fake-timers@8.1.1": version "8.1.1" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3" @@ -2970,11 +2957,6 @@ "@typescript-eslint/types" "6.21.0" eslint-visitor-keys "^3.4.1" -"@ungap/structured-clone@^1.0.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" - integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== - "@vitejs/plugin-react-swc@^3.7.0": version "3.7.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.0.tgz#e456c0a6d7f562268e1d231af9ac46b86ef47d88" @@ -3077,25 +3059,12 @@ resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== -accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - acorn-jsx@^5.2.0, acorn-jsx@^5.3.1, acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" - integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== - -acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1: +acorn@^7.1.1, acorn@^7.4.0: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== @@ -3276,11 +3245,6 @@ array-buffer-byte-length@^1.0.0, array-buffer-byte-length@^1.0.1: call-bind "^1.0.5" is-array-buffer "^3.0.4" -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - array-includes@^3.1.6, array-includes@^3.1.8: version "3.1.8" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" @@ -3568,24 +3532,6 @@ bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -body-parser@1.20.3: - version "1.20.3" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" - integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== - dependencies: - bytes "3.1.2" - content-type "~1.0.5" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.13.0" - raw-body "2.5.2" - type-is "~1.6.18" - unpipe "1.0.0" - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -3674,11 +3620,6 @@ bundle-require@^5.0.0: dependencies: load-tsconfig "^0.2.3" -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - cac@^6.7.14: version "6.7.14" resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" @@ -4003,11 +3944,6 @@ common-tags@^1.8.0: resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -4036,18 +3972,6 @@ consola@^3.2.3: resolved "https://registry.yarnpkg.com/consolidated-events/-/consolidated-events-2.0.2.tgz#da8d8f8c2b232831413d9e190dc11669c79f4a91" integrity sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ== -content-disposition@0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@~1.0.4, content-type@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - convert-source-map@^1.5.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" @@ -4058,12 +3982,7 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== - -cookie@0.6.0, cookie@^0.5.0, cookie@^0.7.0: +cookie@^0.5.0, cookie@^0.7.0: version "0.7.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== @@ -4397,13 +4316,6 @@ dayjs@^1.10.4: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@~4.3.6: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -4502,21 +4414,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -depd@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - dequal@^2.0.0, dequal@^2.0.2, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== -destroy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" @@ -4599,11 +4501,6 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - electron-to-chromium@^1.5.28: version "1.5.28" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.28.tgz#aee074e202c6ee8a0030a9c2ef0b3fe9f967d576" @@ -4629,16 +4526,6 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -encodeurl@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -4783,11 +4670,6 @@ es-iterator-helpers@^1.0.19: iterator.prototype "^1.1.2" safe-array-concat "^1.1.2" -es-module-lexer@^1.5.0: - version "1.5.4" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" - integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== - es-object-atoms@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" @@ -4820,6 +4702,11 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-toolkit@^1.22.0: + version "1.31.0" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.31.0.tgz#f4fc1382aea09cb239afa38f3c724a5658ff3163" + integrity sha512-vwS0lv/tzjM2/t4aZZRAgN9I9TP0MSkWuvt6By+hEXfG/uLs8yg2S1/ayRXH/x3pinbLgVJYT+eppueg3cM6tg== + esbuild-register@^3.5.0: version "3.6.0" resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.6.0.tgz#cf270cfa677baebbc0010ac024b823cbf723a36d" @@ -4827,35 +4714,36 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0", esbuild@^0.23.0, esbuild@~0.23.0: - version "0.23.1" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.1.tgz#40fdc3f9265ec0beae6f59824ade1bd3d3d2dab8" - integrity sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg== +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": + version "0.24.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" + integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== optionalDependencies: - "@esbuild/aix-ppc64" "0.23.1" - "@esbuild/android-arm" "0.23.1" - "@esbuild/android-arm64" "0.23.1" - "@esbuild/android-x64" "0.23.1" - "@esbuild/darwin-arm64" "0.23.1" - "@esbuild/darwin-x64" "0.23.1" - "@esbuild/freebsd-arm64" "0.23.1" - "@esbuild/freebsd-x64" "0.23.1" - "@esbuild/linux-arm" "0.23.1" - "@esbuild/linux-arm64" "0.23.1" - "@esbuild/linux-ia32" "0.23.1" - "@esbuild/linux-loong64" "0.23.1" - "@esbuild/linux-mips64el" "0.23.1" - "@esbuild/linux-ppc64" "0.23.1" - "@esbuild/linux-riscv64" "0.23.1" - "@esbuild/linux-s390x" "0.23.1" - "@esbuild/linux-x64" "0.23.1" - "@esbuild/netbsd-x64" "0.23.1" - "@esbuild/openbsd-arm64" "0.23.1" - "@esbuild/openbsd-x64" "0.23.1" - "@esbuild/sunos-x64" "0.23.1" - "@esbuild/win32-arm64" "0.23.1" - "@esbuild/win32-ia32" "0.23.1" - "@esbuild/win32-x64" "0.23.1" + "@esbuild/aix-ppc64" "0.24.2" + "@esbuild/android-arm" "0.24.2" + "@esbuild/android-arm64" "0.24.2" + "@esbuild/android-x64" "0.24.2" + "@esbuild/darwin-arm64" "0.24.2" + "@esbuild/darwin-x64" "0.24.2" + "@esbuild/freebsd-arm64" "0.24.2" + "@esbuild/freebsd-x64" "0.24.2" + "@esbuild/linux-arm" "0.24.2" + "@esbuild/linux-arm64" "0.24.2" + "@esbuild/linux-ia32" "0.24.2" + "@esbuild/linux-loong64" "0.24.2" + "@esbuild/linux-mips64el" "0.24.2" + "@esbuild/linux-ppc64" "0.24.2" + "@esbuild/linux-riscv64" "0.24.2" + "@esbuild/linux-s390x" "0.24.2" + "@esbuild/linux-x64" "0.24.2" + "@esbuild/netbsd-arm64" "0.24.2" + "@esbuild/netbsd-x64" "0.24.2" + "@esbuild/openbsd-arm64" "0.24.2" + "@esbuild/openbsd-x64" "0.24.2" + "@esbuild/sunos-x64" "0.24.2" + "@esbuild/win32-arm64" "0.24.2" + "@esbuild/win32-ia32" "0.24.2" + "@esbuild/win32-x64" "0.24.2" esbuild@^0.21.3: version "0.21.5" @@ -4886,16 +4774,41 @@ esbuild@^0.21.3: "@esbuild/win32-ia32" "0.21.5" "@esbuild/win32-x64" "0.21.5" +esbuild@^0.23.0, esbuild@~0.23.0: + version "0.23.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.1.tgz#40fdc3f9265ec0beae6f59824ade1bd3d3d2dab8" + integrity sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.23.1" + "@esbuild/android-arm" "0.23.1" + "@esbuild/android-arm64" "0.23.1" + "@esbuild/android-x64" "0.23.1" + "@esbuild/darwin-arm64" "0.23.1" + "@esbuild/darwin-x64" "0.23.1" + "@esbuild/freebsd-arm64" "0.23.1" + "@esbuild/freebsd-x64" "0.23.1" + "@esbuild/linux-arm" "0.23.1" + "@esbuild/linux-arm64" "0.23.1" + "@esbuild/linux-ia32" "0.23.1" + "@esbuild/linux-loong64" "0.23.1" + "@esbuild/linux-mips64el" "0.23.1" + "@esbuild/linux-ppc64" "0.23.1" + "@esbuild/linux-riscv64" "0.23.1" + "@esbuild/linux-s390x" "0.23.1" + "@esbuild/linux-x64" "0.23.1" + "@esbuild/netbsd-x64" "0.23.1" + "@esbuild/openbsd-arm64" "0.23.1" + "@esbuild/openbsd-x64" "0.23.1" + "@esbuild/sunos-x64" "0.23.1" + "@esbuild/win32-arm64" "0.23.1" + "@esbuild/win32-ia32" "0.23.1" + "@esbuild/win32-x64" "0.23.1" + escalade@^3.1.1, escalade@^3.1.2: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -4911,17 +4824,6 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -escodegen@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" - integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== - dependencies: - esprima "^4.0.1" - estraverse "^5.2.0" - esutils "^2.0.2" - optionalDependencies: - source-map "~0.6.1" - eslint-config-prettier@~8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6" @@ -5254,7 +5156,7 @@ espree@^7.3.0, espree@^7.3.1: acorn-jsx "^5.3.1" eslint-visitor-keys "^1.3.0" -esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: +esprima@^4.0.0, esprima@~4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -5300,11 +5202,6 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - eventemitter2@6.4.7: version "6.4.7" resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.7.tgz#a7f6c4d7abf28a14c1ef3442f21cb306a054271d" @@ -5372,43 +5269,6 @@ executable@^4.1.1: dependencies: pify "^2.2.0" -express@^4.19.2: - version "4.21.0" - resolved "https://registry.yarnpkg.com/express/-/express-4.21.0.tgz#d57cb706d49623d4ac27833f1cbc466b668eb915" - integrity sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.3" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.6.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.3.1" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.3" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.10" - proxy-addr "~2.0.7" - qs "6.13.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.19.0" - serve-static "1.16.2" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -5577,41 +5437,11 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -finalhandler@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" - integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== - dependencies: - debug "2.6.9" - encodeurl "~2.0.0" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - -find-cache-dir@^3.0.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" - integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== - dependencies: - commondir "^1.0.1" - make-dir "^3.0.2" - pkg-dir "^4.1.0" - find-root@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== -find-up@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -5714,11 +5544,6 @@ formik@~2.1.3: tiny-warning "^1.0.2" tslib "^1.10.0" -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - framebus@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/framebus/-/framebus-6.0.0.tgz#4ebafaf4d78441fdb1f6c55cb9a6ea9f72c55cff" @@ -5726,20 +5551,6 @@ framebus@6.0.0: dependencies: "@braintree/uuid" "^0.1.0" -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fs-extra@^11.1.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" - integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -5863,11 +5674,6 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -github-slugger@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" - integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== - glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -5882,13 +5688,6 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob-promise@^4.2.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877" - integrity sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw== - dependencies: - "@types/glob" "^7.1.3" - glob@^10.3.1, glob@^10.3.10, glob@^10.4.1: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -5901,7 +5700,7 @@ glob@^10.3.1, glob@^10.3.10, glob@^10.4.1: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^7.1.3, glob@^7.1.6, glob@^7.2.0: +glob@^7.1.3, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -6037,27 +5836,6 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" -hast-util-heading-rank@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz#2d5c6f2807a7af5c45f74e623498dd6054d2aba8" - integrity sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA== - dependencies: - "@types/hast" "^3.0.0" - -hast-util-is-element@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz#6e31a6532c217e5b533848c7e52c9d9369ca0932" - integrity sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g== - dependencies: - "@types/hast" "^3.0.0" - -hast-util-to-string@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.0.tgz#2a131948b4b1b26461a2c8ac876e2c88d02946bd" - integrity sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA== - dependencies: - "@types/hast" "^3.0.0" - he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -6124,11 +5902,6 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== -html-tags@^3.1.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" - integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== - html2canvas@^1.0.0-rc.5: version "1.4.1" resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" @@ -6137,17 +5910,6 @@ html2canvas@^1.0.0-rc.5: css-line-break "^2.1.0" text-segmentation "^1.0.3" -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - http-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" @@ -6193,13 +5955,6 @@ husky@^9.1.6: resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.6.tgz#e23aa996b6203ab33534bdc82306b0cf2cb07d6c" integrity sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A== -iconv-lite@0.4.24, iconv-lite@^0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -6207,6 +5962,13 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +iconv-lite@^0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -6258,7 +6020,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3: +inherits@2, inherits@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6326,7 +6088,7 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" -ipaddr.js@1.9.1, ipaddr.js@^1.9.1: +ipaddr.js@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== @@ -6336,11 +6098,6 @@ ipaddr.js@^2.0.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== -is-absolute-url@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-4.0.1.tgz#16e4d487d4fded05cfe0685e53ec86804a5e94dc" - integrity sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A== - is-arguments@^1.0.4, is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -6532,11 +6289,6 @@ is-plain-obj@^4.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== -is-plain-object@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" - integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== - is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -7045,13 +6797,6 @@ localforage@^1.8.1: dependencies: lie "3.1.1" -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - locate-path@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" @@ -7209,13 +6954,6 @@ magicast@^0.3.4: "@babel/types" "^7.25.4" source-map-js "^1.2.0" -make-dir@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -7244,11 +6982,6 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== -markdown-to-jsx@^7.4.5: - version "7.5.0" - resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.5.0.tgz#42ece0c71e842560a7d8bd9f81e7a34515c72150" - integrity sha512-RrBNcMHiFPcz/iqIj0n3wclzHXjwS7mzjBNWecKKVhNTIxQepIix6Il/wZCn2Cg5Y1ow2Qi84+eJrryFRWBEWw== - md5@^2.2.1, md5@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" @@ -7385,11 +7118,6 @@ mdurl@^1.0.1: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" @@ -7402,11 +7130,6 @@ memoizerific@^1.11.3: dependencies: map-or-similar "^1.5.0" -merge-descriptors@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" - integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -7417,11 +7140,6 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - micromark-core-commonmark@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.1.tgz#9a45510557d068605c6e9a80f282b2bb8581e43d" @@ -7708,18 +7426,13 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@~2.1.19: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" -mime@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -7804,12 +7517,7 @@ mrmime@^2.0.0: resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.0.tgz#151082a6e06e59a9a39b46b3e14d5cfe92b3abb4" integrity sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw== -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.3, ms@^2.1.1, ms@^2.1.3: +ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -7861,10 +7569,10 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.7: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +nanoid@^3.3.7, nanoid@^3.3.8: + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== natural-compare-lite@^1.4.0: version "1.4.0" @@ -7876,11 +7584,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -7999,13 +7702,6 @@ object.values@^1.1.6, object.values@^1.2.0: define-properties "^1.2.1" es-object-atoms "^1.0.0" -on-finished@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -8082,13 +7778,6 @@ outvariant@^1.4.0, outvariant@^1.4.2, outvariant@^1.4.3: resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -8096,13 +7785,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - p-locate@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" @@ -8117,11 +7799,6 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - package-json-from-dist@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" @@ -8151,11 +7828,6 @@ parse5@^7.1.2: dependencies: entities "^4.4.0" -parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -8194,11 +7866,6 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@0.1.10: - version "0.1.10" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" - integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== - path-to-regexp@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.9.0.tgz#5dc0753acbf8521ca2e0f137b4578b917b10cf24" @@ -8275,13 +7942,6 @@ pirates@^4.0.1: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== -pkg-dir@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - polished@^4.2.2: version "4.3.1" resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" @@ -8392,14 +8052,6 @@ property-expr@^2.0.5: resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8" integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA== -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - proxy-from-env@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" @@ -8470,26 +8122,6 @@ ramda@0.25.0, ramda@~0.25.0: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== -range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" - integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - -react-colorful@^5.1.2: - version "5.6.1" - resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" - integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== - react-csv@^2.0.3: version "2.2.2" resolved "https://registry.yarnpkg.com/react-csv/-/react-csv-2.2.2.tgz#5bbf0d72a846412221a14880f294da9d6def9bfb" @@ -8533,15 +8165,6 @@ react-dropzone@~11.2.0: file-selector "^0.2.2" prop-types "^15.7.2" -react-element-to-jsx-string@^15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz#1cafd5b6ad41946ffc8755e254da3fc752a01ac6" - integrity sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ== - dependencies: - "@base2/pretty-print-object" "1.0.1" - is-plain-object "5.0.0" - react-is "18.1.0" - react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" @@ -8559,11 +8182,6 @@ react-input-autosize@^2.2.2: dependencies: prop-types "^15.5.8" -react-is@18.1.0: - version "18.1.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67" - integrity sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg== - react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -8816,29 +8434,6 @@ regexpp@^3.1.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== -rehype-external-links@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/rehype-external-links/-/rehype-external-links-3.0.0.tgz#2b28b5cda1932f83f045b6f80a3e1b15f168c6f6" - integrity sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw== - dependencies: - "@types/hast" "^3.0.0" - "@ungap/structured-clone" "^1.0.0" - hast-util-is-element "^3.0.0" - is-absolute-url "^4.0.0" - space-separated-tokens "^2.0.0" - unist-util-visit "^5.0.0" - -rehype-slug@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/rehype-slug/-/rehype-slug-6.0.0.tgz#1d21cf7fc8a83ef874d873c15e6adaee6344eaf1" - integrity sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A== - dependencies: - "@types/hast" "^3.0.0" - github-slugger "^2.0.0" - hast-util-heading-rank "^3.0.0" - hast-util-to-string "^3.0.0" - unist-util-visit "^5.0.0" - remark-gfm@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.0.tgz#aea777f0744701aa288b67d28c43565c7e8c35de" @@ -9063,7 +8658,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.2: +safe-buffer@^5.0.1, safe-buffer@^5.1.2: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -9114,40 +8709,11 @@ search-string@^3.1.0: resolved "https://registry.yarnpkg.com/search-string/-/search-string-3.1.0.tgz#3f111c6919a33de33a8e304fd5f8395c3d806ffb" integrity sha512-yY3b0VlaXfKi2B//34PN5AFF+GQvwme6Kj4FjggmoSBOa7B8AHfS1nYZbsrYu+IyGeYOAkF8ywL9LN9dkrOo6g== -semver@7.6.0, semver@^5.5.0, semver@^6.0.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.2: +semver@7.6.0, semver@^5.5.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.2: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== -send@0.19.0: - version "0.19.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" - integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - -serve-static@1.16.2: - version "1.16.2" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" - integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== - dependencies: - encodeurl "~2.0.0" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.19.0" - set-function-length@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -9175,11 +8741,6 @@ setimmediate@^1.0.5: resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -9350,11 +8911,6 @@ source-map@^0.6.0, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -space-separated-tokens@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" - integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -9385,7 +8941,7 @@ stackblur-canvas@^2.0.0: resolved "https://registry.yarnpkg.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz#af931277d0b5096df55e1f91c530043e066989b6" integrity sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ== -statuses@2.0.1, statuses@^2.0.1: +statuses@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== @@ -9416,12 +8972,12 @@ storybook-dark-mode@4.0.1: fast-deep-equal "^3.1.3" memoizerific "^1.11.3" -storybook@^8.3.0: - version "8.3.3" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.3.3.tgz#3de9be589815403539660653d2ec810348e7dafb" - integrity sha512-FG2KAVQN54T9R6voudiEftehtkXtLO+YVGP2gBPfacEdDQjY++ld7kTbHzpTT/bpCDx7Yq3dqOegLm9arVJfYw== +storybook@^8.4.7: + version "8.4.7" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.4.7.tgz#a3068787a58074cec1b4197eed1c4427ec644b3f" + integrity sha512-RP/nMJxiWyFc8EVMH5gp20ID032Wvk+Yr3lmKidoegto5Iy+2dVQnUoElZb2zpbVXNHWakGuAkfI0dY1Hfp/vw== dependencies: - "@storybook/core" "8.3.3" + "@storybook/core" "8.4.7" strict-event-emitter@^0.5.1: version "0.5.1" @@ -9687,13 +9243,6 @@ table@^6.0.9: string-width "^4.2.3" strip-ansi "^6.0.1" -telejson@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/telejson/-/telejson-7.2.0.tgz#3994f6c9a8f8d7f2dba9be2c7c5bbb447e876f32" - integrity sha512-1QTEcJkJEhc8OnStBx/ILRu5J2p0GjvWsBx56bmZRqnrkdBMUe+nX92jxV+p3dB4CP6PZCdJMQJwCggkNBMzkQ== - dependencies: - memoizerific "^1.11.3" - test-exclude@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" @@ -9821,11 +9370,6 @@ toggle-selection@^1.0.6: resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - toposort@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" @@ -9998,7 +9542,7 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^2.19.0, type-fest@~2.19: +type-fest@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== @@ -10008,14 +9552,6 @@ type-fest@^4.9.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.26.1.tgz#a4a17fa314f976dd3e6d6675ef6c775c16d7955e" integrity sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg== -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -10159,11 +9695,6 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - unplugin@^1.3.1: version "1.14.1" resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-1.14.1.tgz#c76d6155a661e43e6a897bce6b767a1ecc344c1a" @@ -10205,11 +9736,6 @@ use-sync-external-store@^1.2.2: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== -util-deprecate@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" @@ -10221,11 +9747,6 @@ util@^0.12.5: is-typed-array "^1.1.3" which-typed-array "^1.1.2" -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - utrie@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645" @@ -10253,11 +9774,6 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== -vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" From d51015d0a59f2aa38bf8618bfb3f61a0ba8a9298 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 15 Jan 2025 18:33:06 -0500 Subject: [PATCH 21/42] fix: [M3-9073] - Fix duplicate specs in Cypress Slack / GH notifications (#11489) * Avoid duplicating specs in run command output, reduce Slack failure list to 4 * Add changeset --- packages/manager/.changeset/pr-11489-tests-1736348080190.md | 5 +++++ scripts/junit-summary/formatters/slack-formatter.ts | 2 +- scripts/junit-summary/util/cypress.ts | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11489-tests-1736348080190.md diff --git a/packages/manager/.changeset/pr-11489-tests-1736348080190.md b/packages/manager/.changeset/pr-11489-tests-1736348080190.md new file mode 100644 index 00000000000..3f5de239a65 --- /dev/null +++ b/packages/manager/.changeset/pr-11489-tests-1736348080190.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix test notification formatting and output issues ([#11489](https://github.com/linode/manager/pull/11489)) diff --git a/scripts/junit-summary/formatters/slack-formatter.ts b/scripts/junit-summary/formatters/slack-formatter.ts index 65b2b3dda1f..0e8123d5f71 100644 --- a/scripts/junit-summary/formatters/slack-formatter.ts +++ b/scripts/junit-summary/formatters/slack-formatter.ts @@ -14,7 +14,7 @@ import { cypressRunCommand } from '../util/cypress'; * The Slack notification has a maximum character limit, so we must truncate * the failure results to reduce the risk of hitting that limit. */ -const FAILURE_SUMMARY_LIMIT = 6; +const FAILURE_SUMMARY_LIMIT = 4; /** * Outputs test result summary formatted as a Slack message. diff --git a/scripts/junit-summary/util/cypress.ts b/scripts/junit-summary/util/cypress.ts index e55e3e78d0d..0f345f39eb7 100644 --- a/scripts/junit-summary/util/cypress.ts +++ b/scripts/junit-summary/util/cypress.ts @@ -6,6 +6,7 @@ * @returns Cypress run command to run `testFiles`. */ export const cypressRunCommand = (testFiles: string[]): string => { - const testFilesList = testFiles.join(','); + const dedupedTestFiles = Array.from(new Set(testFiles)); + const testFilesList = dedupedTestFiles.join(','); return `yarn cy:run -s "${testFilesList}"`; }; From ee4ee7a524b68591de7c80db462995da803dfc8f Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Thu, 16 Jan 2025 19:03:59 +0530 Subject: [PATCH 22/42] feat: [DI-22550] - Enhance date range picker component (#11495) * feat: [DI-22550] - Enhanced default value logic * feat: [DI-22550] - Added logic to disable end date calendar dates which are before selected start date * feat: [DI-222550] - Updated test cases * feat: [DI-22550] - Updated test case * feat: [DI-22550] - Updated test cases * feat: [DI-22550] - Added disabled support for timezone * feat: [DI-22550] - Added qa id * feat: [DI-22550] - Added Calcutta as additional timezone for IST * feat: [DI-22550] - Removed error from date picker on click preset button * feat: [DI-22550] - Remove end date error condition * Added changeset * updated changeset * feat: [DI-22550] - Removed unused variable * feat: [DI-22550] - fix typechek error --- .../pr-11495-changed-1736782822155.md | 5 + .../manager/src/assets/timezones/timezones.ts | 5 + .../components/DatePicker/DateTimePicker.tsx | 13 +++ .../DateTimeRangePicker.stories.tsx | 17 ++-- .../DatePicker/DateTimeRangePicker.test.tsx | 34 ++----- .../DatePicker/DateTimeRangePicker.tsx | 91 ++++++++++--------- 6 files changed, 89 insertions(+), 76 deletions(-) create mode 100644 packages/manager/.changeset/pr-11495-changed-1736782822155.md diff --git a/packages/manager/.changeset/pr-11495-changed-1736782822155.md b/packages/manager/.changeset/pr-11495-changed-1736782822155.md new file mode 100644 index 00000000000..6c6e3df22ac --- /dev/null +++ b/packages/manager/.changeset/pr-11495-changed-1736782822155.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +add `Asia/Calcutta` zonename in `timezones.ts`, add `disabledTimeZone` property in `DateTimeRangePicker`, add `minDate` property to `DateTimePicker` ([#11495](https://github.com/linode/manager/pull/11495)) diff --git a/packages/manager/src/assets/timezones/timezones.ts b/packages/manager/src/assets/timezones/timezones.ts index 87655164181..0888381c189 100644 --- a/packages/manager/src/assets/timezones/timezones.ts +++ b/packages/manager/src/assets/timezones/timezones.ts @@ -1290,6 +1290,11 @@ export const timezones = [ name: 'Asia/Kolkata', offset: 5.5, }, + { + label: 'India Standard Time - Calcutta', + name: 'Asia/Calcutta', + offset: 5.5, + }, { label: 'Nepal Time', name: 'Asia/Kathmandu', diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index 86c66ee834a..1cee4f53b99 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -21,12 +21,15 @@ import type { DateTime } from 'luxon'; export interface DateTimePickerProps { /** Additional props for the DateCalendar */ dateCalendarProps?: Partial>; + disabledTimeZone?: boolean; /** Error text for the date picker field */ errorText?: string; /** Format for displaying the date-time */ format?: string; /** Label for the input field */ label?: string; + /** Minimum date-time before which all date-time will be disabled */ + minDate?: DateTime; /** Callback when the "Apply" button is clicked */ onApply?: () => void; /** Callback when the "Cancel" button is clicked */ @@ -61,9 +64,11 @@ export interface DateTimePickerProps { export const DateTimePicker = ({ dateCalendarProps = {}, + disabledTimeZone = false, errorText = '', format = 'yyyy-MM-dd HH:mm', label = 'Select Date and Time', + minDate, onApply, onCancel, onChange, @@ -193,6 +198,7 @@ export const DateTimePicker = ({ > ({ @@ -266,6 +278,7 @@ export const DateTimePicker = ({ {showTimeZone && ( ; export const Default: Story = { args: { + enablePresets: true, endDateProps: { - errorMessage: '', label: 'End Date and Time', placeholder: '', showTimeZone: false, value: null, }, + format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), presetsProps: { - defaultValue: { label: '', value: '' }, - enablePresets: true, + defaultValue: '', label: '', placeholder: '', }, @@ -40,16 +40,17 @@ export const Default: Story = { export const WithInitialValues: Story = { args: { + enablePresets: true, endDateProps: { label: 'End Date and Time', showTimeZone: true, value: DateTime.now(), }, + format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), presetsProps: { - defaultValue: { label: 'Last 7 Days', value: '7days' }, - enablePresets: true, + defaultValue: '7days', label: 'Time Range', placeholder: 'Select Range', }, @@ -65,8 +66,8 @@ export const WithInitialValues: Story = { export const WithCustomErrors: Story = { args: { + enablePresets: true, endDateProps: { - errorMessage: 'End date must be after the start date.', label: 'Custom End Label', placeholder: '', showTimeZone: false, @@ -75,8 +76,8 @@ export const WithCustomErrors: Story = { format: 'yyyy-MM-dd HH:mm', onChange: action('DateTime range changed'), presetsProps: { - defaultValue: { label: '', value: '' }, - enablePresets: true, + defaultValue: '', + label: '', placeholder: '', }, diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx index 3dc542f4c36..1011c755345 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -12,12 +12,12 @@ import type { DateTimeRangePickerProps } from './DateTimeRangePicker'; const onChangeMock = vi.fn(); const Props: DateTimeRangePickerProps = { + enablePresets: true, endDateProps: { label: 'End Date and Time', }, onChange: onChangeMock, presetsProps: { - enablePresets: true, label: 'Date Presets', }, @@ -74,7 +74,7 @@ describe('DateTimeRangePicker Component', () => { }); }); - it('should show error when end date-time is before start date-time', async () => { + it('should disable the end date-time which is before the selected start date-time', async () => { renderWithTheme(); // Set start date-time to the 15th @@ -87,20 +87,14 @@ describe('DateTimeRangePicker Component', () => { const endDateField = screen.getByLabelText('End Date and Time'); await userEvent.click(endDateField); - // Set start date-time to the 10th - await userEvent.click(screen.getByRole('gridcell', { name: '10' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Confirm error message is displayed - expect( - screen.getByText('End date/time cannot be before the start date/time.') - ).toBeInTheDocument(); + expect(screen.getByRole('gridcell', { name: '10' })).toBeDisabled(); }); it('should show error when start date-time is after end date-time', async () => { const updateProps = { ...Props, - presetsProps: { ...Props.presetsProps, enablePresets: false }, + enablePresets: false, + presetsProps: { ...Props.presetsProps }, }; renderWithTheme(); @@ -125,6 +119,7 @@ describe('DateTimeRangePicker Component', () => { it('should display custom error messages when start date-time is after end date-time', async () => { const updatedProps = { ...Props, + enablePresets: false, endDateProps: { ...Props.endDateProps, errorMessage: 'Custom end date error', @@ -323,24 +318,9 @@ describe('DateTimeRangePicker Component', () => { await userEvent.click(endDateField); // Set start date-time to the 12th - await userEvent.click(screen.getByRole('gridcell', { name: '12' })); + await userEvent.click(screen.getByRole('gridcell', { name: '17' })); await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - // Confirm error message is shown since the click was blocked - expect( - screen.getByText('End date/time cannot be before the start date/time.') - ).toBeInTheDocument(); - - // Set start date-time to the 11th - await userEvent.click(startDateField); - await userEvent.click(screen.getByRole('gridcell', { name: '11' })); - await userEvent.click(screen.getByRole('button', { name: 'Apply' })); - - // Confirm error message is not displayed - expect( - screen.queryByText('End date/time cannot be before the start date/time.') - ).not.toBeInTheDocument(); - // Set start date-time to the 20th await userEvent.click(startDateField); await userEvent.click(screen.getByRole('gridcell', { name: '20' })); diff --git a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx index 7083ba7bee8..a83aab1206e 100644 --- a/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimeRangePicker.tsx @@ -9,10 +9,14 @@ import { DateTimePicker } from './DateTimePicker'; import type { SxProps, Theme } from '@mui/material/styles'; export interface DateTimeRangePickerProps { + /** If true, disable the timezone drop down */ + disabledTimeZone?: boolean; + + /** If true, shows the date presets field instead of the date pickers */ + enablePresets?: boolean; + /** Properties for the end date field */ endDateProps?: { - /** Custom error message for invalid end date */ - errorMessage?: string; /** Label for the end date field */ label?: string; /** placeholder for the end date field */ @@ -40,9 +44,7 @@ export interface DateTimeRangePickerProps { /** Additional settings for the presets dropdown */ presetsProps?: { /** Default value for the presets field */ - defaultValue?: { label: string; value: string }; - /** If true, shows the date presets field instead of the date pickers */ - enablePresets?: boolean; + defaultValue?: string; /** Label for the presets field */ label?: string; /** placeholder for the presets field */ @@ -71,13 +73,17 @@ export interface DateTimeRangePickerProps { type DatePresetType = | '7days' + | '12hours' | '24hours' | '30days' + | '30minutes' | 'custom_range' | 'last_month' | 'this_month'; const presetsOptions: { label: string; value: DatePresetType }[] = [ + { label: 'Last 30 Minutes', value: '30minutes' }, + { label: 'Last 12 Hours', value: '12hours' }, { label: 'Last 24 Hours', value: '24hours' }, { label: 'Last 7 Days', value: '7days' }, { label: 'Last 30 Days', value: '30days' }, @@ -88,21 +94,20 @@ const presetsOptions: { label: string; value: DatePresetType }[] = [ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { const { + disabledTimeZone = false, + + enablePresets = false, + endDateProps: { - errorMessage: endDateErrorMessage = 'End date/time cannot be before the start date/time.', label: endLabel = 'End Date and Time', placeholder: endDatePlaceholder, showTimeZone: showEndTimeZone = false, value: endDateTimeValue = null, } = {}, - format = 'yyyy-MM-dd HH:mm', - onChange, - presetsProps: { - defaultValue: presetsDefaultValue = { label: '', value: '' }, - enablePresets = false, + defaultValue: presetsDefaultValue = presetsOptions[0].value, label: presetsLabel = 'Time Range', placeholder: presetsPlaceholder = 'Select a preset', } = {}, @@ -123,17 +128,25 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { const [endDateTime, setEndDateTime] = useState( endDateTimeValue ); - const [presetValue, setPresetValue] = useState<{ - label: string; - value: string; - }>(presetsDefaultValue); + const [presetValue, setPresetValue] = useState< + | { + label: string; + value: string; + } + | undefined + >( + presetsOptions.find((option) => option.value === presetsDefaultValue) ?? + presetsOptions[0] + ); const [startTimeZone, setStartTimeZone] = useState( startTimeZoneValue ); const [startDateError, setStartDateError] = useState(null); - const [endDateError, setEndDateError] = useState(null); - const [showPresets, setShowPresets] = useState(enablePresets); - + const [showPresets, setShowPresets] = useState( + presetsDefaultValue + ? presetsDefaultValue !== 'custom_range' && enablePresets + : enablePresets + ); const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -142,38 +155,34 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { end: DateTime | null, source: 'end' | 'start' ) => { - if (start && end) { - if (source === 'start' && start > end) { - setStartDateError(startDateErrorMessage); - return; - } - if (source === 'end' && end < start) { - setEndDateError(endDateErrorMessage); - return; - } + if (start && end && source === 'start' && start > end) { + setStartDateError(startDateErrorMessage); + return; } // Reset validation errors setStartDateError(null); - setEndDateError(null); }; const handlePresetSelection = (value: DatePresetType) => { const now = DateTime.now(); let newStartDateTime: DateTime | null = null; - let newEndDateTime: DateTime | null = null; + let newEndDateTime: DateTime | null = now; switch (value) { + case '30minutes': + newStartDateTime = now.minus({ minutes: 30 }); + break; + case '12hours': + newStartDateTime = now.minus({ hours: 12 }); + break; case '24hours': newStartDateTime = now.minus({ hours: 24 }); - newEndDateTime = now; break; case '7days': newStartDateTime = now.minus({ days: 7 }); - newEndDateTime = now; break; case '30days': newStartDateTime = now.minus({ days: 30 }); - newEndDateTime = now; break; case 'this_month': newStartDateTime = now.startOf('month'); @@ -196,7 +205,7 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { setEndDateTime(newEndDateTime); setPresetValue( presetsOptions.find((option) => option.value === value) ?? - presetsDefaultValue + presetsOptions[0] ); if (onChange) { @@ -248,7 +257,8 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { handlePresetSelection(selection.value as DatePresetType); } }} - defaultValue={presetsDefaultValue} + data-qa-preset="preset-select" + data-testid="preset-select" disableClearable fullWidth label={presetsLabel} @@ -269,6 +279,7 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { onChange: (value) => setStartTimeZone(value), value: startTimeZone, }} + disabledTimeZone={disabledTimeZone} errorText={startDateError ?? undefined} format={format} label={startLabel} @@ -282,24 +293,22 @@ export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => { timeZoneSelectProps={{ value: startTimeZone, }} - errorText={endDateError ?? undefined} + disabledTimeZone={disabledTimeZone} format={format} label={endLabel} + minDate={startDateTime || undefined} onChange={handleEndDateTimeChange} placeholder={endDatePlaceholder} showTimeZone={showEndTimeZone} timeSelectProps={{ label: 'End Time' }} value={endDateTime} /> - + { setShowPresets(true); - setPresetValue(presetsDefaultValue); + setPresetValue(undefined); + setStartDateError(null); }} variant="text" > From ecc0357e61d958c2e04bebbbcbaedccc70562898 Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:47:42 -0500 Subject: [PATCH 23/42] upcoming: [M3-8921] - Limits Evolution foundations (#11493) * Add quota API types and endpoints * Add limitsEvolution feature flag * Update Quotas API types * Add Quotas to MSW CRUD preset * Added changeset: Quotas feature flag and MSW CRUD preset support * Added changeset: Types for Quotas endpoints * Move changeset to upcoming * Add quota query keys and hooks * Latest API changes * Feedback @abailly-akamai --- .../pr-11493-added-1736811098382.md | 5 + packages/api-v4/src/index.ts | 2 + packages/api-v4/src/quotas/index.ts | 3 + packages/api-v4/src/quotas/quotas.ts | 36 +++++ packages/api-v4/src/quotas/types.ts | 70 ++++++++++ .../pr-11493-upcoming-1736811045709.md | 5 + .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/factories/quotas.ts | 13 ++ packages/manager/src/featureFlags.ts | 1 + .../src/mocks/presets/baseline/crud.ts | 2 + .../src/mocks/presets/crud/handlers/quotas.ts | 130 ++++++++++++++++++ .../manager/src/mocks/presets/crud/quotas.ts | 10 ++ packages/manager/src/mocks/types.ts | 2 + packages/manager/src/queries/quotas/quotas.ts | 63 +++++++++ .../manager/src/queries/quotas/requests.ts | 18 +++ 15 files changed, 361 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-11493-added-1736811098382.md create mode 100644 packages/api-v4/src/quotas/index.ts create mode 100644 packages/api-v4/src/quotas/quotas.ts create mode 100644 packages/api-v4/src/quotas/types.ts create mode 100644 packages/manager/.changeset/pr-11493-upcoming-1736811045709.md create mode 100644 packages/manager/src/factories/quotas.ts create mode 100644 packages/manager/src/mocks/presets/crud/handlers/quotas.ts create mode 100644 packages/manager/src/mocks/presets/crud/quotas.ts create mode 100644 packages/manager/src/queries/quotas/quotas.ts create mode 100644 packages/manager/src/queries/quotas/requests.ts diff --git a/packages/api-v4/.changeset/pr-11493-added-1736811098382.md b/packages/api-v4/.changeset/pr-11493-added-1736811098382.md new file mode 100644 index 00000000000..6046d0b39b5 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11493-added-1736811098382.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Types for Quotas endpoints ([#11493](https://github.com/linode/manager/pull/11493)) diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index dd996b686d2..fab28fabeb5 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -32,6 +32,8 @@ export * from './placement-groups'; export * from './profile'; +export * from './quotas'; + export * from './regions'; export * from './stackscripts'; diff --git a/packages/api-v4/src/quotas/index.ts b/packages/api-v4/src/quotas/index.ts new file mode 100644 index 00000000000..2b1b25700ed --- /dev/null +++ b/packages/api-v4/src/quotas/index.ts @@ -0,0 +1,3 @@ +export * from './types'; + +export * from './quotas'; diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts new file mode 100644 index 00000000000..ecb9b8057d1 --- /dev/null +++ b/packages/api-v4/src/quotas/quotas.ts @@ -0,0 +1,36 @@ +import { Filter, Params, ResourcePage as Page } from 'src/types'; +import { API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; +import { Quota, QuotaType } from './types'; + +/** + * getQuota + * + * Returns the details for a single quota within a particular service specified by `type`. + * + * @param type { QuotaType } retrieve a quota within this service type. + * @param id { number } the quota ID to look up. + */ +export const getQuota = (type: QuotaType, id: number) => + Request(setURL(`${API_ROOT}/${type}/quotas/${id}`), setMethod('GET')); + +/** + * getQuotas + * + * Returns a paginated list of quotas for a particular service specified by `type`. + * + * This request can be filtered on `quota_name`, `service_name` and `scope`. + * + * @param type { QuotaType } retrieve quotas within this service type. + */ +export const getQuotas = ( + type: QuotaType, + params: Params = {}, + filter: Filter = {} +) => + Request>( + setURL(`${API_ROOT}/${type}/quotas`), + setMethod('GET'), + setXFilter(filter), + setParams(params) + ); diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts new file mode 100644 index 00000000000..23c42f00165 --- /dev/null +++ b/packages/api-v4/src/quotas/types.ts @@ -0,0 +1,70 @@ +import { ObjectStorageEndpointTypes } from 'src/object-storage'; +import { Region } from 'src/regions'; + +/** + * A Quota is a service used limit that is rated based on service metrics such + * as vCPUs used, instances or storage size. + */ +export interface Quota { + /** + * A unique identifier for the quota. + */ + quota_id: number; + + /** + * Customer facing label describing the quota. + */ + quota_name: string; + + /** + * Longer explanatory description for the quota. + */ + description: string; + + /** + * The account-wide limit for this service, measured in units + * specified by the `resource_metric` field. + */ + quota_limit: number; + + /** + * Current account usage, measured in units specified by the + * `resource_metric` field. + */ + used: number; + + /** + * The unit of measurement for this service limit. + */ + resource_metric: + | 'instance' + | 'CPU' + | 'GPU' + | 'VPU' + | 'cluster' + | 'node' + | 'bucket' + | 'object' + | 'byte'; + + /** + * The region slug to which this limit applies. + */ + region_applied: Region['id'] | 'global'; + + /** + * The OBJ endpoint type to which this limit applies. + * + * For OBJ limits only. + */ + endpoint_type?: ObjectStorageEndpointTypes; + + /** + * The S3 endpoint URL to which this limit applies. + * + * For OBJ limits only. + */ + s3_endpoint?: string; +} + +export type QuotaType = 'linode' | 'lke' | 'object-storage'; diff --git a/packages/manager/.changeset/pr-11493-upcoming-1736811045709.md b/packages/manager/.changeset/pr-11493-upcoming-1736811045709.md new file mode 100644 index 00000000000..9ecac06aef0 --- /dev/null +++ b/packages/manager/.changeset/pr-11493-upcoming-1736811045709.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming +--- + +Quotas feature flag, queries, and MSW CRUD preset support ([#11493](https://github.com/linode/manager/pull/11493)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index a11cf2ab320..d1cf640975b 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -26,6 +26,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'gecko2', label: 'Gecko' }, { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, { flag: 'imageServiceGen2Ga', label: 'Image Service Gen2 GA' }, + { flag: 'limitsEvolution', label: 'Limits Evolution' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'lkeEnterprise', label: 'LKE-Enterprise' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, diff --git a/packages/manager/src/factories/quotas.ts b/packages/manager/src/factories/quotas.ts new file mode 100644 index 00000000000..739c62ccc36 --- /dev/null +++ b/packages/manager/src/factories/quotas.ts @@ -0,0 +1,13 @@ +import Factory from 'src/factories/factoryProxy'; + +import type { Quota } from '@linode/api-v4/lib/quotas/types'; + +export const quotaFactory = Factory.Sync.makeFactory({ + description: 'Maximimum number of vCPUs allowed', + quota_id: Factory.each((id) => id), + quota_limit: 50, + quota_name: 'Linode Dedicated vCPUs', + region_applied: 'us-east', + resource_metric: 'CPU', + used: 25, +}); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 38d5300b4cc..1cc71554ccf 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -121,6 +121,7 @@ export interface Flags { imageServiceGen2: boolean; imageServiceGen2Ga: boolean; ipv6Sharing: boolean; + limitsEvolution: BaseFeatureFlag; linodeDiskEncryption: boolean; lkeEnterprise: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index 12679014c81..6a82cef03dc 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -6,6 +6,7 @@ import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes'; import { domainCrudPreset } from '../crud/domains'; import { placementGroupsCrudPreset } from '../crud/placementGroups'; +import { quotasCrudPreset } from '../crud/quotas'; import { supportTicketCrudPreset } from '../crud/supportTickets'; import { volumeCrudPreset } from '../crud/volumes'; @@ -16,6 +17,7 @@ export const baselineCrudPreset: MockPresetBaseline = { handlers: [ ...linodeCrudPreset.handlers, ...placementGroupsCrudPreset.handlers, + ...quotasCrudPreset.handlers, ...supportTicketCrudPreset.handlers, ...volumeCrudPreset.handlers, ...domainCrudPreset.handlers, diff --git a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts new file mode 100644 index 00000000000..d01593a8214 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts @@ -0,0 +1,130 @@ +import { http } from 'msw'; + +import { quotaFactory } from 'src/factories/quotas'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import type { Quota, QuotaType } from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +const mockQuotas: Record = { + linode: [ + quotaFactory.build({ + description: + 'Max number of vCPUs assigned to Linodes with Dedicated plans', + quota_limit: 10, + quota_name: 'Dedicated CPU', + region_applied: 'us-east', + resource_metric: 'CPU', + used: 8, + }), + quotaFactory.build({ + description: 'Max number of vCPUs assigned to Linodes with Shared plans', + quota_limit: 25, + quota_name: 'Shared CPU', + region_applied: 'us-east', + resource_metric: 'CPU', + used: 22, + }), + quotaFactory.build({ + description: 'Max number of GPUs assigned to Linodes with GPU plans', + quota_limit: 10, + quota_name: 'GPU', + region_applied: 'us-east', + resource_metric: 'GPU', + used: 5, + }), + quotaFactory.build({ + description: 'Max number of VPUs assigned to Linodes with VPU plans', + quota_limit: 100, + quota_name: 'VPU', + region_applied: 'us-east', + resource_metric: 'VPU', + used: 20, + }), + quotaFactory.build({ + description: + 'Max number of vCPUs assigned to Linodes with High Memory plans', + quota_limit: 30, + quota_name: 'High Memory', + region_applied: 'us-east', + resource_metric: 'CPU', + used: 0, + }), + ], + lke: [ + quotaFactory.build({ + quota_limit: 20, + quota_name: 'Total number of Clusters', + region_applied: 'us-east', + resource_metric: 'cluster', + used: 12, + }), + ], + 'object-storage': [ + quotaFactory.build({ + endpoint_type: 'E3', + quota_limit: 1_000_000_000_000_000, // a petabyte + quota_name: 'Total Capacity', + region_applied: 'us-east', + resource_metric: 'byte', + s3_endpoint: 'us-east-1.linodeobjects.com', + used: 900_000_000_000_000, + }), + quotaFactory.build({ + endpoint_type: 'E3', + quota_limit: 1000, + quota_name: 'Number of Buckets', + region_applied: 'us-east', + resource_metric: 'bucket', + s3_endpoint: 'us-east-1.linodeobjects.com', + }), + quotaFactory.build({ + endpoint_type: 'E3', + quota_limit: 10_000_000, + quota_name: 'Number of Objects', + region_applied: 'us-east', + resource_metric: 'object', + s3_endpoint: 'us-east-1.linodeobjects.com', + }), + ], +}; + +export const getQuotas = () => [ + http.get( + '*/v4/:service/quotas', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + return makePaginatedResponse({ + data: mockQuotas[params.service as QuotaType], + request, + }); + } + ), + + http.get( + '*/v4/:service/quotas/:id', + async ({ params }): Promise> => { + const quota = mockQuotas[params.service as QuotaType].find( + ({ quota_id }) => quota_id === +params.id + ); + + if (!quota) { + return makeNotFoundResponse(); + } + + return makeResponse(quota); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/quotas.ts b/packages/manager/src/mocks/presets/crud/quotas.ts new file mode 100644 index 00000000000..49ea495f568 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/quotas.ts @@ -0,0 +1,10 @@ +import { getQuotas } from './handlers/quotas'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const quotasCrudPreset: MockPresetCrud = { + group: { id: 'Quotas' }, + handlers: [getQuotas], + id: 'quotas:crud', + label: 'Quotas CRUD', +}; diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 7597a52cd2f..1ef6da194fb 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -85,6 +85,7 @@ export type MockPresetCrudGroup = { | 'Domains' | 'Linodes' | 'Placement Groups' + | 'Quotas' | 'Support Tickets' | 'Volumes'; }; @@ -92,6 +93,7 @@ export type MockPresetCrudId = | 'domains:crud' | 'linodes:crud' | 'placement-groups:crud' + | 'quotas:crud' | 'support-tickets:crud' | 'volumes:crud'; export interface MockPresetCrud extends MockPresetBase { diff --git a/packages/manager/src/queries/quotas/quotas.ts b/packages/manager/src/queries/quotas/quotas.ts new file mode 100644 index 00000000000..8f1ba05067c --- /dev/null +++ b/packages/manager/src/queries/quotas/quotas.ts @@ -0,0 +1,63 @@ +import { getQuota, getQuotas } from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import { getAllQuotas } from './requests'; + +import type { + APIError, + Filter, + Params, + Quota, + QuotaType, + ResourcePage, +} from '@linode/api-v4'; + +export const quotaQueries = createQueryKeys('quotas', { + service: (type: QuotaType) => ({ + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllQuotas(type, params, filter), + queryKey: [params, filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getQuotas(type, params, filter), + queryKey: [params, filter], + }), + quota: (id: number) => ({ + queryFn: () => getQuota(type, id), + queryKey: [id], + }), + }, + queryKey: [type], + }), +}); + +export const useQuotaQuery = (service: QuotaType, id: number, enabled = true) => + useQuery({ + ...quotaQueries.service(service)._ctx.quota(id), + enabled, + }); + +export const useQuotasQuery = ( + service: QuotaType, + params: Params = {}, + filter: Filter, + enabled = true +) => + useQuery, APIError[]>({ + ...quotaQueries.service(service)._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + }); + +export const useAllQuotasQuery = ( + service: QuotaType, + params: Params = {}, + filter: Filter, + enabled = true +) => + useQuery({ + ...quotaQueries.service(service)._ctx.all(params, filter), + enabled, + }); diff --git a/packages/manager/src/queries/quotas/requests.ts b/packages/manager/src/queries/quotas/requests.ts new file mode 100644 index 00000000000..3230da3acf2 --- /dev/null +++ b/packages/manager/src/queries/quotas/requests.ts @@ -0,0 +1,18 @@ +import { getQuotas } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +import type { Filter, Params, Quota, QuotaType } from '@linode/api-v4'; + +export const getAllQuotas = ( + service: QuotaType, + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getQuotas( + service, + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); From 397c3ce84d78d4fbcf3a1a40ec4d3a496f577db5 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 16 Jan 2025 18:00:25 -0500 Subject: [PATCH 24/42] test: [M3-9123] - Reset test account preferences when Cypress starts (#11522) * Reset test account preferences when environment variable is set * Reset GHA test account preferences on run start * Added changeset: Add `CY_TEST_RESET_PREFERENCES` env var to reset user preferences at test run start --- .github/workflows/e2e_schedule_and_push.yml | 1 + docker-compose.yml | 1 + docs/development-guide/08-testing.md | 1 + .../pr-11522-tests-1737048531721.md | 5 ++++ packages/manager/cypress.config.ts | 2 ++ .../support/plugins/reset-user-preferences.ts | 25 +++++++++++++++++++ 6 files changed, 35 insertions(+) create mode 100644 packages/manager/.changeset/pr-11522-tests-1737048531721.md create mode 100644 packages/manager/cypress/support/plugins/reset-user-preferences.ts diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index 63578c1a25c..e95da872490 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -7,6 +7,7 @@ env: USER_4: ${{ secrets.USER_4 }} CLIENT_ID: ${{ secrets.REACT_APP_CLIENT_ID }} CY_TEST_FAIL_ON_MANAGED: 1 + CY_TEST_RESET_PREFERENCES: 1 on: schedule: - cron: "0 13 * * 1-5" diff --git a/docker-compose.yml b/docker-compose.yml index ced361320f6..b3cea0e4449 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ x-e2e-env: CY_TEST_FEATURE_FLAGS: ${CY_TEST_FEATURE_FLAGS} CY_TEST_TAGS: ${CY_TEST_TAGS} CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} + CY_TEST_RESET_PREFERENCES: ${CY_TEST_RESET_PREFERENCES} # Cypress environment variables for alternative parallelization. CY_TEST_SPLIT_RUN: ${CY_TEST_SPLIT_RUN} diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 0581601ac73..27ad221daee 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -223,6 +223,7 @@ Environment variables related to Cypress logging and reporting, as well as repor | `CY_TEST_DISABLE_RETRIES` | Disable test retries on failure in CI | `1` | Unset; disabled by default | | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | | `CY_TEST_GENWEIGHTS` | Generate and output test weights to the given path | `./weights.json` | Unset; disabled by default | +| `CY_TEST_RESET_PREFERENCES` | Reset user preferences when test run begins | `1` | Unset; disabled by default | ###### Performance diff --git a/packages/manager/.changeset/pr-11522-tests-1737048531721.md b/packages/manager/.changeset/pr-11522-tests-1737048531721.md new file mode 100644 index 00000000000..1399ee99153 --- /dev/null +++ b/packages/manager/.changeset/pr-11522-tests-1737048531721.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add `CY_TEST_RESET_PREFERENCES` env var to reset user preferences at test run start ([#11522](https://github.com/linode/manager/pull/11522)) diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index 83903ec697c..b8596bed4d9 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -19,6 +19,7 @@ import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; import cypressViteConfig from './cypress/vite.config'; import { featureFlagOverrides } from './cypress/support/plugins/feature-flag-override'; import { postRunCleanup } from './cypress/support/plugins/post-run-cleanup'; +import { resetUserPreferences } from './cypress/support/plugins/reset-user-preferences'; /** * Exports a Cypress configuration object. @@ -92,6 +93,7 @@ export default defineConfig({ discardPassedTestRecordings, fetchAccount, fetchLinodeRegions, + resetUserPreferences, regionOverrideCheck, featureFlagOverrides, logTestTagInfo, diff --git a/packages/manager/cypress/support/plugins/reset-user-preferences.ts b/packages/manager/cypress/support/plugins/reset-user-preferences.ts new file mode 100644 index 00000000000..313a9215ecb --- /dev/null +++ b/packages/manager/cypress/support/plugins/reset-user-preferences.ts @@ -0,0 +1,25 @@ +import { CypressPlugin } from './plugin'; +import { updateUserPreferences } from '@linode/api-v4'; + +const envVarName = 'CY_TEST_RESET_PREFERENCES'; + +/** + * Resets test account user preferences to expected state when + * `CY_TEST_RESET_PREFERENCES` is set. + */ +export const resetUserPreferences: CypressPlugin = async (_on, config) => { + if (config.env[envVarName]) { + await updateUserPreferences({ + // Sidebar categories are fully expanded. + collapsedSideNavProductFamilies: [], + + // Sidebar is not pinned. + desktop_sidebar_open: false, + + // Type-to-confirm is enabled. + type_to_confirm: true, + }); + + console.info('Reset test account user preferences'); + } +}; From 90a7be76f2ad45198d431878c3837fab2d483b0d Mon Sep 17 00:00:00 2001 From: Ankita Date: Fri, 17 Jan 2025 09:09:11 +0530 Subject: [PATCH 25/42] fix: [DI-22875] - Zoom-in icon hover effect fix in CloudPulse (#11526) * upcoming: [DI-22875] - Zoom-in icon hover effect fix * upcoming: [DI-22875] - Naming correction * upcoming: [DI-22875] - Test case fix * upcoming: [DI-22875] - Add changeset * upcoming: [DI-22875] - Cypress update --- .../.changeset/pr-11526-fixed-1737034837256.md | 5 +++++ .../dbaas-widgets-verification.spec.ts | 4 ++-- .../linode-widget-verification.spec.ts | 4 ++-- packages/manager/src/assets/icons/zoomin.svg | 16 ++++++++-------- packages/manager/src/assets/icons/zoomout.svg | 18 +++++++++--------- .../Widget/CloudPulseWidget.test.tsx | 4 ++-- .../Widget/components/Zoomer.test.tsx | 8 ++++---- .../CloudPulse/Widget/components/Zoomer.tsx | 12 ++++++------ 8 files changed, 38 insertions(+), 33 deletions(-) create mode 100644 packages/manager/.changeset/pr-11526-fixed-1737034837256.md diff --git a/packages/manager/.changeset/pr-11526-fixed-1737034837256.md b/packages/manager/.changeset/pr-11526-fixed-1737034837256.md new file mode 100644 index 00000000000..2e6209ed284 --- /dev/null +++ b/packages/manager/.changeset/pr-11526-fixed-1737034837256.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Zoom-in icon hover effect fix in CloudPulse ([#11526](https://github.com/linode/manager/pull/11526)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 5bed5edc465..854077817ba 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -432,7 +432,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { .should('be.visible') .within(() => { ui.button - .findByAttribute('aria-label', 'Zoom In') + .findByAttribute('aria-label', 'Zoom Out') .should('be.visible') .should('be.enabled') .click(); @@ -464,7 +464,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { // click zoom out and validate the same ui.button - .findByAttribute('aria-label', 'Zoom Out') + .findByAttribute('aria-label', 'Zoom In') .should('be.visible') .should('be.enabled') .scrollIntoView() diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 70a6ef1c615..5b4afb0f031 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -404,7 +404,7 @@ describe('Integration Tests for Linode Dashboard ', () => { .should('be.visible') .within(() => { ui.button - .findByAttribute('aria-label', 'Zoom In') + .findByAttribute('aria-label', 'Zoom Out') .should('be.visible') .should('be.enabled') .click(); @@ -435,7 +435,7 @@ describe('Integration Tests for Linode Dashboard ', () => { // click zoom out and validate the same ui.button - .findByAttribute('aria-label', 'Zoom Out') + .findByAttribute('aria-label', 'Zoom In') .should('be.visible') .should('be.enabled') .scrollIntoView() diff --git a/packages/manager/src/assets/icons/zoomin.svg b/packages/manager/src/assets/icons/zoomin.svg index fcb722675ef..86d7a2a4f4c 100644 --- a/packages/manager/src/assets/icons/zoomin.svg +++ b/packages/manager/src/assets/icons/zoomin.svg @@ -1,10 +1,10 @@ - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/zoomout.svg b/packages/manager/src/assets/icons/zoomout.svg index 7021f3a5f61..fcb722675ef 100644 --- a/packages/manager/src/assets/icons/zoomout.svg +++ b/packages/manager/src/assets/icons/zoomout.svg @@ -1,10 +1,10 @@ - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx index 9524fd02f2a..476ad9997ac 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx @@ -124,7 +124,7 @@ describe('Cloud pulse widgets', () => { expect(getByTestId('Aggregation function')).toBeInTheDocument(); // Verify zoom icon - expect(getByTestId('zoom-in')).toBeInTheDocument(); + expect(getByTestId('zoom-out')).toBeInTheDocument(); // Verify graph component expect( @@ -146,7 +146,7 @@ describe('Cloud pulse widgets', () => { it('should update preferences for zoom toggle', async () => { const { getByTestId } = renderWithTheme(); - const zoomButton = getByTestId('zoom-in'); + const zoomButton = getByTestId('zoom-out'); await userEvent.click(zoomButton); expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', { size: 6, diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx index 40ab57a50f3..06e31143700 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx @@ -10,21 +10,21 @@ describe('Cloud Pulse Zoomer', () => { it('Should render zoomer with zoom-out button', () => { const props: ZoomIconProperties = { handleZoomToggle: vi.fn(), - zoomIn: false, + zoomIn: true, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-out')).toBeInTheDocument(); - expect(getByTestId('Maximize')).toBeInTheDocument(); // test id for tooltip + expect(getByTestId('Minimize')).toBeInTheDocument(); // test id for tooltip }), it('Should render zoomer with zoom-in button', () => { const props: ZoomIconProperties = { handleZoomToggle: vi.fn(), - zoomIn: true, + zoomIn: false, }; const { getByTestId } = renderWithTheme(); expect(getByTestId('zoom-in')).toBeInTheDocument(); - expect(getByTestId('Minimize')).toBeInTheDocument(); // test id for tooltip + expect(getByTestId('Maximize')).toBeInTheDocument(); // test id for tooltip }); }); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx index e54155352ff..85666e556c2 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx @@ -29,11 +29,11 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { fontSize: 'x-large', padding: 0, }} - aria-label="Zoom In" - data-testid="zoom-in" + aria-label="Zoom Out" + data-testid="zoom-out" onClick={() => handleClick(false)} > - + ); @@ -47,11 +47,11 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { fontSize: 'x-large', padding: 0, }} - aria-label="Zoom Out" - data-testid="zoom-out" + aria-label="Zoom In" + data-testid="zoom-in" onClick={() => handleClick(true)} > - + ); From e9d3ac58d3a8cc7a71515d6a5acb7814dc75e2fe Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 17 Jan 2025 11:24:04 +0530 Subject: [PATCH 26/42] refactor: [M3-8252] - Remove ramda from `DomainRecords` (Part 1) (#11514) * Remove ramda from `DomainRecords` pt1 * Added changeset: Remove ramda from `DomainRecords` pt1 --- .../pr-11514-tech-stories-1736784891293.md | 5 ++ .../DomainRecords/DomainRecordActionMenu.tsx | 3 +- .../DomainRecords/DomainRecords.tsx | 11 ++-- .../DomainRecords/DomainRecordsUtils.ts | 41 ++++++++++++--- .../DomainRecords/generateTypes.tsx | 51 +++++++++++-------- 5 files changed, 78 insertions(+), 33 deletions(-) create mode 100644 packages/manager/.changeset/pr-11514-tech-stories-1736784891293.md diff --git a/packages/manager/.changeset/pr-11514-tech-stories-1736784891293.md b/packages/manager/.changeset/pr-11514-tech-stories-1736784891293.md new file mode 100644 index 00000000000..57a2154c5da --- /dev/null +++ b/packages/manager/.changeset/pr-11514-tech-stories-1736784891293.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove ramda from `DomainRecords` pt1 ([#11514](https://github.com/linode/manager/pull/11514)) diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx index 4f5cadbd70a..1c1b4014d32 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordActionMenu.tsx @@ -1,4 +1,3 @@ -import { has } from 'ramda'; import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; @@ -49,7 +48,7 @@ export const DomainRecordActionMenu = (props: DomainRecordActionMenuProps) => { }, title: 'Edit', }, - has('deleteData', props) + Boolean(props.deleteData) ? { onClick: () => { handleDelete(); diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx index d0d824f5bc3..eb5e4b9d48f 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -1,7 +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 { lensPath, over } from 'ramda'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -196,7 +195,10 @@ export const DomainRecords = (props: Props) => { fn: (confirmDialog: ConfirmationState) => ConfirmationState ) => { setState((prevState) => { - const newState = over(lensPath(['confirmDialog']), fn, prevState); + const newState = { + ...prevState, + confirmDialog: fn(prevState.confirmDialog), + }; scrollErrorIntoViewV2(confirmDialogRef); return newState; @@ -205,7 +207,10 @@ export const DomainRecords = (props: Props) => { const updateDrawer = (fn: (drawer: DrawerState) => DrawerState) => { setState((prevState) => { - return over(lensPath(['drawer']), fn, prevState); + return { + ...prevState, + drawer: fn(prevState.drawer), + }; }); }; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts index e60047db478..b5baa8a5ed1 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordsUtils.ts @@ -1,10 +1,19 @@ -import { compose, pathOr } from 'ramda'; - import type { Props } from './DomainRecords'; -import type { DomainRecord, RecordType } from '@linode/api-v4/lib/domains'; +import type { + Domain, + DomainRecord, + RecordType, +} from '@linode/api-v4/lib/domains'; + +type DomainTimeFields = Pick< + Domain, + 'expire_sec' | 'refresh_sec' | 'retry_sec' | 'ttl_sec' +>; -export const msToReadableTime = (v: number): null | string => - pathOr(null, [v], { +type DomainRecordTimeFields = Pick; + +export const msToReadableTime = (v: number): null | string => { + const msToReadableTimeMap: { [key: number]: string } = { 0: 'Default', 30: '30 seconds', 120: '2 minutes', @@ -20,9 +29,27 @@ export const msToReadableTime = (v: number): null | string => 604800: '1 week', 1209600: '2 weeks', 2419200: '4 weeks', - }); + }; + + return v in msToReadableTimeMap ? msToReadableTimeMap[v] : null; +}; + +export function getTimeColumn( + record: Domain, + keyPath: keyof DomainTimeFields +): null | string; + +export function getTimeColumn( + record: DomainRecord, + keyPath: keyof DomainRecordTimeFields +): null | string; -export const getTTL = compose(msToReadableTime, pathOr(0, ['ttl_sec'])); +export function getTimeColumn( + record: Domain | DomainRecord, + keyPath: keyof (DomainRecordTimeFields | DomainTimeFields) +) { + return msToReadableTime(record[keyPath] ?? 0); +} export const typeEq = (type: RecordType) => (record: DomainRecord): boolean => record.type === type; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx index 8fc76d749f7..29c23c111b3 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/generateTypes.tsx @@ -1,16 +1,10 @@ import { Button } from '@linode/ui'; -import { compose, isEmpty, pathOr } from 'ramda'; import React from 'react'; import { truncateEnd } from 'src/utilities/truncate'; import { DomainRecordActionMenu } from './DomainRecordActionMenu'; -import { - getNSRecords, - getTTL, - msToReadableTime, - typeEq, -} from './DomainRecordsUtils'; +import { getNSRecords, getTimeColumn, typeEq } from './DomainRecordsUtils'; import type { Props as DomainRecordsProps } from './DomainRecords'; import type { Domain, DomainRecord } from '@linode/api-v4/lib/domains'; @@ -88,19 +82,19 @@ export const generateTypes = ( title: 'Email', }, { - render: getTTL, + render: (domain: Domain) => getTimeColumn(domain, 'ttl_sec'), title: 'Default TTL', }, { - render: compose(msToReadableTime, pathOr(0, ['refresh_sec'])), + render: (domain: Domain) => getTimeColumn(domain, 'refresh_sec'), title: 'Refresh Rate', }, { - render: compose(msToReadableTime, pathOr(0, ['retry_sec'])), + render: (domain: Domain) => getTimeColumn(domain, 'retry_sec'), title: 'Retry Rate', }, { - render: compose(msToReadableTime, pathOr(0, ['expire_sec'])), + render: (domain: Domain) => getTimeColumn(domain, 'expire_sec'), title: 'Expire Time', }, { @@ -132,14 +126,14 @@ export const generateTypes = ( { render: (record: DomainRecord) => { const subdomain = record.name; - return isEmpty(subdomain) - ? props.domain.domain - : `${subdomain}.${props.domain.domain}`; + return Boolean(subdomain) + ? `${subdomain}.${props.domain.domain}` + : props.domain.domain; }, title: 'Subdomain', }, { - render: getTTL, + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), title: 'TTL', }, { @@ -197,7 +191,7 @@ export const generateTypes = ( title: 'Subdomain', }, { - render: getTTL, + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), title: 'TTL', }, { @@ -239,7 +233,10 @@ export const generateTypes = ( title: 'Hostname', }, { render: (record: DomainRecord) => record.target, title: 'IP Address' }, - { render: getTTL, title: 'TTL' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, { render: (domainRecordParams: DomainRecord) => { const { id, name, target, ttl_sec } = domainRecordParams; @@ -278,7 +275,10 @@ export const generateTypes = ( columns: [ { render: (record: DomainRecord) => record.name, title: 'Hostname' }, { render: (record: DomainRecord) => record.target, title: 'Aliases to' }, - { render: getTTL, title: 'TTL' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, { render: (domainRecordParams: DomainRecord) => { const { id, name, target, ttl_sec } = domainRecordParams; @@ -321,7 +321,10 @@ export const generateTypes = ( render: (record: DomainRecord) => truncateEnd(record.target, 100), title: 'Value', }, - { render: getTTL, title: 'TTL' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, { render: (domainRecordParams: DomainRecord) => { const { id, name, target, ttl_sec } = domainRecordParams; @@ -372,7 +375,10 @@ export const generateTypes = ( }, { render: (record: DomainRecord) => String(record.port), title: 'Port' }, { render: (record: DomainRecord) => record.target, title: 'Target' }, - { render: getTTL, title: 'TTL' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, { render: ({ id, @@ -421,7 +427,10 @@ export const generateTypes = ( render: (record: DomainRecord) => record.target, title: 'Value', }, - { render: getTTL, title: 'TTL' }, + { + render: (record: DomainRecord) => getTimeColumn(record, 'ttl_sec'), + title: 'TTL', + }, { render: (domainRecordParams: DomainRecord) => { const { id, name, tag, target, ttl_sec } = domainRecordParams; From 7123228f3350393ffb220e2587bdabb0b1ec2139 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 17 Jan 2025 16:51:32 +0100 Subject: [PATCH 27/42] feat: [UIE-8136] - add new users table component (part 2) (#11402) --- .../pr-11402-added-1736419050008.md | 5 + ...r-11402-upcoming-features-1733927050433.md | 5 + .../IAM/Shared/UserDeleteConfirmation.tsx | 73 +++++++++ .../UsersTable/CreateUserDrawer.test.tsx | 74 +++++++++ .../IAM/Users/UsersTable/CreateUserDrawer.tsx | 149 ++++++++++++++++++ .../IAM/Users/UsersTable/ProxyUserTable.tsx | 68 ++++++++ .../features/IAM/Users/UsersTable/Users.tsx | 104 +++++++++--- .../UsersLandingProxyTableHead.test.tsx | 52 ++++++ .../UsersTable/UsersLandingProxyTableHead.tsx | 45 ++++++ packages/manager/src/queries/account/users.ts | 20 ++- 10 files changed, 574 insertions(+), 21 deletions(-) create mode 100644 packages/manager/.changeset/pr-11402-added-1736419050008.md create mode 100644 packages/manager/.changeset/pr-11402-upcoming-features-1733927050433.md create mode 100644 packages/manager/src/features/IAM/Shared/UserDeleteConfirmation.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx create mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx diff --git a/packages/manager/.changeset/pr-11402-added-1736419050008.md b/packages/manager/.changeset/pr-11402-added-1736419050008.md new file mode 100644 index 00000000000..966a79ca909 --- /dev/null +++ b/packages/manager/.changeset/pr-11402-added-1736419050008.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +useCreateUserMutation for adding new users ([#11402](https://github.com/linode/manager/pull/11402)) diff --git a/packages/manager/.changeset/pr-11402-upcoming-features-1733927050433.md b/packages/manager/.changeset/pr-11402-upcoming-features-1733927050433.md new file mode 100644 index 00000000000..add377adaf0 --- /dev/null +++ b/packages/manager/.changeset/pr-11402-upcoming-features-1733927050433.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +proxy users table, removing users, adding users ([#11402](https://github.com/linode/manager/pull/11402)) diff --git a/packages/manager/src/features/IAM/Shared/UserDeleteConfirmation.tsx b/packages/manager/src/features/IAM/Shared/UserDeleteConfirmation.tsx new file mode 100644 index 00000000000..316673a89a0 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/UserDeleteConfirmation.tsx @@ -0,0 +1,73 @@ +import { Notice, Typography } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { useAccountUserDeleteMutation } from 'src/queries/account/users'; + +interface Props { + onClose: () => void; + onSuccess?: () => void; + open: boolean; + username: string; +} + +export const UserDeleteConfirmation = (props: Props) => { + const { onClose: _onClose, onSuccess, open, username } = props; + + const { enqueueSnackbar } = useSnackbar(); + + const { + error, + isPending, + mutateAsync: deleteUser, + reset, + } = useAccountUserDeleteMutation(username); + + const onClose = () => { + reset(); // resets the error state of the useMutation + _onClose(); + }; + + const onDelete = async () => { + await deleteUser(); + enqueueSnackbar(`User ${username} has been deleted successfully.`, { + variant: 'success', + }); + if (onSuccess) { + onSuccess(); + } + onClose(); + }; + + return ( + + } + error={error?.[0].reason} + onClose={onClose} + open={open} + title={`Delete user ${username}?`} + > + + + Warning: Deleting this User is permanent and canโ€™t be + undone. +
    + + + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx new file mode 100644 index 00000000000..da6329a686b --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CreateUserDrawer } from './CreateUserDrawer'; + +const props = { + onClose: vi.fn(), + open: true, +}; + +const testEmail = 'testuser@example.com'; + +describe('CreateUserDrawer', () => { + it('should render the drawer when open is true', () => { + const { getByRole } = renderWithTheme(); + + const dialog = getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + }); + + it('should allow the user to fill out the form', () => { + const { getByLabelText, getByRole } = renderWithTheme( + + ); + + const dialog = getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + + fireEvent.change(getByLabelText(/username/i), { + target: { value: 'testuser' }, + }); + fireEvent.change(getByLabelText(/email/i), { + target: { value: testEmail }, + }); + + expect(getByLabelText(/username/i)).toHaveValue('testuser'); + expect(getByLabelText(/email/i)).toHaveValue(testEmail); + }); + + it('should display an error message when submission fails', async () => { + server.use( + http.post('*/account/users', () => { + return HttpResponse.json( + { error: [{ reason: 'An unexpected error occurred.' }] }, + { status: 500 } + ); + }) + ); + + const { + findByText, + getByLabelText, + getByRole, + getByTestId, + } = renderWithTheme(); + + const dialog = getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + + fireEvent.change(getByLabelText(/username/i), { + target: { value: 'testuser' }, + }); + fireEvent.change(getByLabelText(/email/i), { + target: { value: testEmail }, + }); + fireEvent.click(getByTestId('submit')); + + const errorMessage = await findByText('An unexpected error occurred.'); + expect(errorMessage).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx new file mode 100644 index 00000000000..674838be112 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/CreateUserDrawer.tsx @@ -0,0 +1,149 @@ +import { Box, FormControlLabel, Notice, TextField, Toggle } from '@linode/ui'; +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { useCreateUserMutation } from 'src/queries/account/users'; + +import type { User } from '@linode/api-v4/lib/account'; + +interface Props { + onClose: () => void; + open: boolean; +} + +export const CreateUserDrawer = (props: Props) => { + const { onClose, open } = props; + const history = useHistory(); + const { mutateAsync: createUserMutation } = useCreateUserMutation(); + + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + setError, + } = useForm({ + defaultValues: { + email: '', + restricted: false, + username: '', + }, + }); + + const onSubmit = async (data: { + email: string; + restricted: boolean; + username: string; + }) => { + try { + const user: User = await createUserMutation(data); + handleClose(); + + if (user.restricted) { + history.push(`/account/users/${data.username}/permissions`, { + newUsername: user.username, + }); + } + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + } + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + return ( + + {errors.root?.message && ( + + )} +
    + ( + + )} + control={control} + name="username" + rules={{ required: 'Username is required' }} + /> + + ( + + )} + control={control} + name="email" + rules={{ required: 'Email is required' }} + /> + + ( + { + field.onChange(!e.target.checked); + }} + checked={!field.value} + data-qa-create-restricted + /> + } + label={`This user will have ${ + field.value ? 'limited' : 'full' + } access to account features. + This can be changed later.`} + sx={{ marginTop: 1 }} + /> + )} + control={control} + name="restricted" + /> + + + + + + +
    + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx b/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx new file mode 100644 index 00000000000..df9a2bf468a --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx @@ -0,0 +1,68 @@ +import { Typography } from '@linode/ui'; +import React from 'react'; + +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { PARENT_USER } from 'src/features/Account/constants'; +import { useAccountUsers } from 'src/queries/account/users'; + +import { UsersLandingProxyTableHead } from './UsersLandingProxyTableHead'; +import { UsersLandingTableBody } from './UsersLandingTableBody'; + +import type { Order } from './UsersLandingTableHead'; + +interface Props { + handleDelete: (username: string) => void; + isProxyUser: boolean; + isRestrictedUser: boolean | undefined; + order: Order; +} + +export const ProxyUserTable = ({ + handleDelete, + isProxyUser, + isRestrictedUser, + order, +}: Props) => { + const { + data: proxyUser, + error: proxyUserError, + isLoading: isLoadingProxyUser, + } = useAccountUsers({ + enabled: isProxyUser && !isRestrictedUser, + filters: { user_type: 'proxy' }, + }); + + const proxyNumCols = 3; + + return ( + <> + ({ + marginBottom: theme.spacing(2), + marginTop: theme.spacing(3), + textTransform: 'capitalize', + [theme.breakpoints.down('md')]: { + marginLeft: theme.spacing(1), + }, + })} + variant="h3" + > + {PARENT_USER} Settings + + + + + + + +
    + + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index b4bcb50bf78..2db2bfac1f5 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -1,7 +1,8 @@ -import { Box, Button, Paper } from '@linode/ui'; +import { getAPIFilterFromQuery } from '@linode/search'; +import { Box, Button, Paper, Typography } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import React from 'react'; +import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -11,24 +12,45 @@ import { TableBody } from 'src/components/TableBody'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountUsers } from 'src/queries/account/users'; +import { useProfile } from 'src/queries/profile/profile'; +import { UserDeleteConfirmation } from '../../Shared/UserDeleteConfirmation'; +import { CreateUserDrawer } from './CreateUserDrawer'; +import { ProxyUserTable } from './ProxyUserTable'; import { UsersLandingTableBody } from './UsersLandingTableBody'; import { UsersLandingTableHead } from './UsersLandingTableHead'; import type { Filter } from '@linode/api-v4'; export const UsersLanding = () => { + const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState( + false + ); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [selectedUsername, setSelectedUsername] = React.useState(''); + + const [query, setQuery] = useState(); + + const { data: profile } = useProfile(); const theme = useTheme(); const pagination = usePagination(1, 'account-users'); const order = useOrder(); + const isProxyUser = + profile?.user_type === 'child' || profile?.user_type === 'proxy'; + + const { error: searchError, filter } = getAPIFilterFromQuery(query, { + searchableFieldsWithoutOperator: ['username', 'email'], + }); + const usersFilter: Filter = { ['+order']: order.order, ['+order_by']: order.orderBy, + ...filter, }; // Since this query is disabled for restricted users, use isLoading. - const { data: users, error, isLoading } = useAccountUsers({ + const { data: users, error, isFetching, isLoading } = useAccountUsers({ filters: usersFilter, params: { page: pagination.page, @@ -36,6 +58,8 @@ export const UsersLanding = () => { }, }); + const isRestrictedUser = profile?.restricted; + const isSmDown = useMediaQuery(theme.breakpoints.down('sm')); const isLgDown = useMediaQuery(theme.breakpoints.up('lg')); @@ -44,16 +68,21 @@ export const UsersLanding = () => { const numCols = isSmDown ? 2 : numColsLg; const handleDelete = (username: string) => { - // mock - }; - - const handleSearch = async (value: string) => { - // mock + setIsDeleteDialogOpen(true); + setSelectedUsername(username); }; return ( + {isProxyUser && ( + + )} ({ marginTop: theme.spacing(2) })}> ({ @@ -63,17 +92,43 @@ export const UsersLanding = () => { marginBottom: theme.spacing(2), })} > - - + {isProxyUser ? ( + ({ + [theme.breakpoints.down('md')]: { + marginLeft: theme.spacing(1), + }, + })} + variant="h3" + > + User Settings + + ) : ( + + )} + @@ -83,7 +138,7 @@ export const UsersLanding = () => { isLoading={isLoading} numCols={numCols} onDelete={handleDelete} - users={users?.data} + users={users?.data ?? []} />
    @@ -96,6 +151,15 @@ export const UsersLanding = () => { pageSize={pagination.pageSize} />
    + setIsCreateDrawerOpen(false)} + open={isCreateDrawerOpen} + /> + setIsDeleteDialogOpen(false)} + open={isDeleteDialogOpen} + username={selectedUsername} + />
    ); }; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx new file mode 100644 index 00000000000..b3111495e39 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UsersLandingProxyTableHead } from './UsersLandingProxyTableHead'; + +import type { SortOrder } from './UsersLandingTableHead'; + +const mockOrder = { + handleOrderChange: vi.fn(), + order: 'asc' as SortOrder, + orderBy: 'username', +}; + +describe('UsersLandingProxyTableHead', () => { + it('should render Username cell', () => { + const { getByText } = renderWithTheme( + + ); + + const username = getByText('Username'); + expect(username).toBeInTheDocument(); + }); + + it('should call handleOrderChange when Username sort cell is clicked', () => { + const { getByText } = renderWithTheme( + + ); + + const usernameCell = getByText('Username'); + expect(usernameCell).toBeInTheDocument(); + fireEvent.click(usernameCell); + + // Expect the handleOrderChange to have been called + expect(mockOrder.handleOrderChange).toHaveBeenCalled(); + }); + + it('should render correctly with order props', () => { + const { getByText } = renderWithTheme( + + ); + + const usernameCell = getByText('Username'); + expect(usernameCell).toBeInTheDocument(); + + expect(usernameCell.closest('span')).toHaveAttribute( + 'aria-label', + 'Sort by username' + ); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx new file mode 100644 index 00000000000..b41f1e600a5 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Hidden } from 'src/components/Hidden'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; + +import type { Order } from './UsersLandingTableHead'; + +interface Props { + order: Order; +} + +export const UsersLandingProxyTableHead = ({ order }: Props) => { + return ( + + + + Username + + + + Email Address + + + + + + ); +}; diff --git a/packages/manager/src/queries/account/users.ts b/packages/manager/src/queries/account/users.ts index ae720f52cb7..d6c05b3caa0 100644 --- a/packages/manager/src/queries/account/users.ts +++ b/packages/manager/src/queries/account/users.ts @@ -1,4 +1,4 @@ -import { deleteUser, updateUser } from '@linode/api-v4'; +import { createUser, deleteUser, updateUser } from '@linode/api-v4'; import { keepPreviousData, useMutation, @@ -119,3 +119,21 @@ function getIsBlocklistedUser(username: string) { } return false; } + +export const useCreateUserMutation = () => { + const queryClient = useQueryClient(); + + return useMutation>({ + mutationFn: (data) => createUser(data), + onSuccess: (user) => { + queryClient.invalidateQueries({ + queryKey: accountQueries.users._ctx.paginated._def, + }); + + queryClient.setQueryData( + accountQueries.users._ctx.user(user.username).queryKey, + user + ); + }, + }); +}; From fa4e589d2aee7237adc726a1b7b22f9f504876a3 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Fri, 17 Jan 2025 12:40:26 -0500 Subject: [PATCH 28/42] upcoming: [M3-9071] - Display cluster provisioning after an LKE-E cluster is created (#11518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description ๐Ÿ“ We want to account for the differences in timing with node provisioning in LKE-E vs standard. For standard LKE, nodes are returned right when the cluster is created (and display status of provisioning), but that is not possible in enterprise because the machine resources are created only once the cluster is ready. So for the first ~5 minutes after a LKE-E cluster's creation, the details page displays 'No data to display' under node pools and this delay is not explained to the user. This PR improves the UX by displaying a cluster provisioning message when a cluster has been created within the first 10 minutes and there have been no nodes returned yet and also surfaces the number of nodes for each pool as added confirmation that the node allocation is correct. ## Changes ๐Ÿ”„ List any change(s) relevant to the reviewer. - Display cluster provisioning message in the cluster detail page if the cluster was created within the first 10 mins with no nodes returned - Display number of nodes for each pool - Update NodePool unit test ## How to test ๐Ÿงช ### Prerequisites (How to setup test environment) - Ensure you have LKE-E customer tags (check project tracker) ``` yarn test NodeTable ``` ### Verification steps (How to verify changes) - [ ] Create a LKE-E cluster - [ ] Observe the cluster details page. You should first see a cluster provisioning message in the Node Pools table while the cluster is provisioning. After ~5mins, you should see that replaced with the provisioning nodes - [ ] Unit tests pass and there are no regressions in the standard LKE cluster flow --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-11518-upcoming-features-1736876400893.md | 5 + .../e2e/core/kubernetes/lke-create.spec.ts | 14 +++ .../src/assets/icons/empty-state-cloud.svg | 10 ++ .../KubernetesClusterDetail.tsx | 2 + .../NodePoolsDisplay/NodePool.tsx | 22 +++- .../NodePoolsDisplay.test.tsx | 6 +- .../NodePoolsDisplay/NodePoolsDisplay.tsx | 18 ++- .../NodePoolsDisplay/NodeTable.test.tsx | 67 +++++++++-- .../NodePoolsDisplay/NodeTable.tsx | 107 ++++++++++++++---- 9 files changed, 215 insertions(+), 36 deletions(-) create mode 100644 packages/manager/.changeset/pr-11518-upcoming-features-1736876400893.md create mode 100644 packages/manager/src/assets/icons/empty-state-cloud.svg diff --git a/packages/manager/.changeset/pr-11518-upcoming-features-1736876400893.md b/packages/manager/.changeset/pr-11518-upcoming-features-1736876400893.md new file mode 100644 index 00000000000..abb2ab0f026 --- /dev/null +++ b/packages/manager/.changeset/pr-11518-upcoming-features-1736876400893.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Display cluster provisioning after an LKE-E cluster is created ([#11518](https://github.com/linode/manager/pull/11518)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 1f0e42c010a..e44b08523a2 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -58,6 +58,7 @@ import { latestKubernetesVersion, } from 'support/constants/lke'; import { lkeEnterpriseTypeFactory } from 'src/factories'; +import { pluralize } from 'src/utilities/pluralize'; const dedicatedNodeCount = 4; const nanodeNodeCount = 3; @@ -317,6 +318,11 @@ describe('LKE Cluster Creation', () => { .should('have.length', similarNodePoolCount) .first() .should('be.visible'); + + // Confirm total number of nodes are shown for each pool + cy.findAllByText( + pluralize('Node', 'Nodes', clusterPlan.nodeCount) + ).should('be.visible'); }); ui.breadcrumb @@ -1073,6 +1079,7 @@ describe('LKE Cluster Creation with LKE-E', () => { * - Confirms an LKE-E supported k8 version can be selected * - Confirms the checkout bar displays the correct LKE-E info * - Confirms an enterprise cluster can be created with the correct chip, version, and price + * - Confirms that the total node count for each pool is displayed */ it('creates an LKE-E cluster with the account capability', () => { const clusterLabel = randomLabel(); @@ -1268,6 +1275,13 @@ describe('LKE Cluster Creation with LKE-E', () => { `Version ${latestEnterpriseTierKubernetesVersion.id}` ).should('be.visible'); cy.findByText('$459.00/month').should('be.visible'); + + clusterPlans.forEach((clusterPlan) => { + // Confirm total number of nodes are shown for each pool + cy.findAllByText( + pluralize('Node', 'Nodes', clusterPlan.nodeCount) + ).should('be.visible'); + }); }); it('disables the Cluster Type selection without the LKE-E account capability', () => { diff --git a/packages/manager/src/assets/icons/empty-state-cloud.svg b/packages/manager/src/assets/icons/empty-state-cloud.svg new file mode 100644 index 00000000000..4710689eee9 --- /dev/null +++ b/packages/manager/src/assets/icons/empty-state-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx index 515f8c69513..192065229a0 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx @@ -124,9 +124,11 @@ export const KubernetesClusterDetail = () => { )} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx index 66f7e915a47..886e49d8b78 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx @@ -6,22 +6,28 @@ import { Tooltip, Typography, } from '@linode/ui'; +import Divider from '@mui/material/Divider'; import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Hidden } from 'src/components/Hidden'; +import { pluralize } from 'src/utilities/pluralize'; import { NodeTable } from './NodeTable'; import type { AutoscaleSettings, + KubernetesTier, PoolNodeResponse, } from '@linode/api-v4/lib/kubernetes'; import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; interface Props { autoscaler: AutoscaleSettings; + clusterCreated: string; clusterId: number; + clusterTier: KubernetesTier; + count: number; encryptionStatus: EncryptionStatus | undefined; handleClickResize: (poolId: number) => void; isOnlyNodePool: boolean; @@ -38,7 +44,10 @@ interface Props { export const NodePool = (props: Props) => { const { autoscaler, + clusterCreated, clusterId, + clusterTier, + count, encryptionStatus, handleClickResize, isOnlyNodePool, @@ -65,7 +74,16 @@ export const NodePool = (props: Props) => { py: 0, }} > - {typeLabel} + + {typeLabel} + ({ height: 16, margin: `4px ${theme.spacing(1)}` })} + /> + + {pluralize('Node', 'Nodes', count)} + + {
    { - const { clusterID, clusterLabel, clusterRegionId, regionsData } = props; + const { + clusterCreated, + clusterID, + clusterLabel, + clusterRegionId, + clusterTier, + regionsData, + } = props; const { data: pools, @@ -104,7 +113,7 @@ export const NodePoolsDisplay = (props: Props) => { {poolsError && } {_pools?.map((thisPool) => { - const { disk_encryption, id, nodes, tags } = thisPool; + const { count, disk_encryption, id, nodes, tags } = thisPool; const thisPoolType = types?.find( (thisType) => thisType.id === thisPool.type @@ -131,7 +140,10 @@ export const NodePoolsDisplay = (props: Props) => { setIsRecycleNodeOpen(true); }} autoscaler={thisPool.autoscaler} + clusterCreated={clusterCreated} clusterId={clusterID} + clusterTier={clusterTier} + count={count} encryptionStatus={disk_encryption} handleClickResize={handleOpenResizeDrawer} isOnlyNodePool={pools?.length === 1} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 33b73495240..7dafe67a0d1 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -1,19 +1,35 @@ +import { DateTime } from 'luxon'; import * as React from 'react'; import { kubeLinodeFactory } from 'src/factories/kubernetesCluster'; import { linodeFactory } from 'src/factories/linodes'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NodeTable, encryptionStatusTestId } from './NodeTable'; import type { Props } from './NodeTable'; +import type { KubernetesTier } from '@linode/api-v4'; -const mockLinodes = linodeFactory.buildList(3); +const mockLinodes = new Array(3) + .fill(null) + .map((_element: null, index: number) => { + return linodeFactory.build({ + ipv4: [`50.116.6.${index}`], + }); + }); -const mockKubeNodes = kubeLinodeFactory.buildList(3); +const mockKubeNodes = mockLinodes.map((mockLinode) => + kubeLinodeFactory.build({ + instance_id: mockLinode.id, + }) +); const props: Props = { + clusterCreated: '2025-01-13T02:58:58', clusterId: 1, + clusterTier: 'standard', encryptionStatus: 'enabled', nodes: mockKubeNodes, openRecycleNodeDialog: vi.fn(), @@ -48,13 +64,25 @@ describe('NodeTable', () => { }; }); - it('includes label, status, and IP columns', () => { - const { findByText } = renderWithTheme(); - mockLinodes.forEach(async (thisLinode) => { - await findByText(thisLinode.label); - await findByText(thisLinode.ipv4[0]); - await findByText('Ready'); - }); + it('includes label, status, and IP columns', async () => { + server.use( + http.get('*/linode/instances*', () => { + return HttpResponse.json(makeResourcePage(mockLinodes)); + }) + ); + + const { findAllByText, findByText } = renderWithTheme( + + ); + + expect(await findAllByText('Running')).toHaveLength(3); + + await Promise.all( + mockLinodes.map(async (mockLinode) => { + await findByText(mockLinode.label); + await findByText(mockLinode.ipv4[0]); + }) + ); }); it('includes the Pool ID', () => { @@ -62,6 +90,27 @@ describe('NodeTable', () => { getByText('Pool ID 1'); }); + it('displays a provisioning message if the cluster was created within the first 10 mins and there are no nodes yet', async () => { + const clusterProps = { + ...props, + clusterCreated: DateTime.local().toISO(), + clusterTier: 'enterprise' as KubernetesTier, + nodes: [], + }; + + const { findByText } = renderWithTheme(); + + expect( + await findByText( + 'Nodes will appear once cluster provisioning is complete.' + ) + ).toBeVisible(); + + expect( + await findByText('Provisioning can take up to 10 minutes.') + ).toBeVisible(); + }); + it('does not display the encryption status of the pool if the account lacks the capability or the feature flag is off', () => { // situation where isDiskEncryptionFeatureEnabled === false const { queryByTestId } = renderWithTheme(); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index e19ee92496f..57220f59e61 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -1,11 +1,14 @@ import { Box, TooltipIcon, Typography } from '@linode/ui'; +import { DateTime, Interval } from 'luxon'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; +import EmptyStateCloud from 'src/assets/icons/empty-state-cloud.svg'; import Lock from 'src/assets/icons/lock.svg'; import Unlock from 'src/assets/icons/unlock.svg'; import { DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY } from 'src/components/Encryption/constants'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -19,6 +22,8 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useProfile } from 'src/queries/profile/profile'; +import { parseAPIDate } from 'src/utilities/date'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { NodeRow as _NodeRow } from './NodeRow'; @@ -31,12 +36,17 @@ import { } from './NodeTable.styles'; import type { NodeRow } from './NodeRow'; -import type { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; +import type { + KubernetesTier, + PoolNodeResponse, +} from '@linode/api-v4/lib/kubernetes'; import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; import type { LinodeWithMaintenance } from 'src/utilities/linodes'; export interface Props { + clusterCreated: string; clusterId: number; + clusterTier: KubernetesTier; encryptionStatus: EncryptionStatus | undefined; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; @@ -49,7 +59,9 @@ export const encryptionStatusTestId = 'encryption-status-fragment'; export const NodeTable = React.memo((props: Props) => { const { + clusterCreated, clusterId, + clusterTier, encryptionStatus, nodes, openRecycleNodeDialog, @@ -58,6 +70,8 @@ export const NodeTable = React.memo((props: Props) => { typeLabel, } = props; + const { data: profile } = useProfile(); + const { data: linodes, error, isLoading } = useAllLinodesQuery(); const { isDiskEncryptionFeatureEnabled, @@ -84,6 +98,27 @@ export const NodeTable = React.memo((props: Props) => { const rowData = nodes.map((thisNode) => nodeToRow(thisNode, linodes ?? [])); + // It takes ~5 minutes for LKE-E cluster nodes to be provisioned and we want to explain this to the user + // since nodes are not returned right away unlike standard LKE + const isEnterpriseClusterWithin10MinsOfCreation = () => { + if (clusterTier !== 'enterprise') { + return false; + } + + const createdTime = parseAPIDate(clusterCreated).setZone(profile?.timezone); + + const interval = Interval.fromDateTimes( + createdTime, + createdTime.plus({ minutes: 10 }) + ); + + const currentTime = DateTime.fromISO(DateTime.now().toISO(), { + zone: profile?.timezone, + }); + + return interval.contains(currentTime); + }; + return ( {({ data: orderedData, handleOrderChange, order, orderBy }) => ( @@ -140,28 +175,56 @@ export const NodeTable = React.memo((props: Props) => { - - {paginatedAndOrderedData.map((eachRow) => { - return ( - <_NodeRow - instanceId={eachRow.instanceId} - instanceStatus={eachRow.instanceStatus} - ip={eachRow.ip} - key={`node-row-${eachRow.nodeId}`} - label={eachRow.label} - linodeError={error ?? undefined} - nodeId={eachRow.nodeId} - nodeStatus={eachRow.nodeStatus} - openRecycleNodeDialog={openRecycleNodeDialog} - typeLabel={typeLabel} + {count === 0 && isEnterpriseClusterWithin10MinsOfCreation() && ( + + + + + Nodes will appear once cluster provisioning is + complete. + + + Provisioning can take up to 10 minutes. + + + } + CustomIcon={EmptyStateCloud} + compact /> - ); - })} - + + + )} + {(count > 0 || + !isEnterpriseClusterWithin10MinsOfCreation()) && ( + + {paginatedAndOrderedData.map((eachRow) => { + return ( + <_NodeRow + instanceId={eachRow.instanceId} + instanceStatus={eachRow.instanceStatus} + ip={eachRow.ip} + key={`node-row-${eachRow.nodeId}`} + label={eachRow.label} + linodeError={error ?? undefined} + nodeId={eachRow.nodeId} + nodeStatus={eachRow.nodeStatus} + openRecycleNodeDialog={openRecycleNodeDialog} + typeLabel={typeLabel} + /> + ); + })} + + )} From 57253d608fff537c47e0fe7fd5f14ac9b69f82ac Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Sat, 18 Jan 2025 06:54:47 +0530 Subject: [PATCH 29/42] upcoming: [DI-22838] - Add Scaffolding for resources section in Cloud Pulse Alert details page (#11524) * upcoming: [DI-22132] - Initial changes for adding resources section * upcoming: [DI-22838] - Added unit tests * upcoming: [DI-22838] - Error message corrections * upcoming: [DI-22838] - Add a skeletal table * upcoming: [DI-22838] - Update comments * upcoming: [DI-22838] - Add UT for utils * upcoming: [DI-22838] - Code refactoring * upcoming: [DI-22838] - Add changeset * upcoming: [DI-22838] - Code refactoring for region filter * upcoming: [DI-22838] - Code refactoring for region filter * upcoming: [DI-22838] - Updated comments * upcoming: [DI-22838] - Code refactoring for utils * upcoming: [DI-22838] - Code refactoring * upcoming: [DI-22838] - Code comments * upcoming: [DI-22838] - Removed error text * upcoming: [DI-22838] - UT update for utils * upcoming: [DI-22838] - UT updates * upcoming: [DI-22838] - Import update for TableBody * upcoming: [DI-22838] - Event handler updates * upcoming: [DI-22838] - ESlint issue updates * upcoming: [DI-22838] - Variable name updates --------- Co-authored-by: vmangalr --- ...r-11524-upcoming-features-1737011022935.md | 5 + .../Alerts/AlertsDetail/AlertDetail.test.tsx | 33 ++++- .../Alerts/AlertsDetail/AlertDetail.tsx | 14 +- .../AlertsRegionFilter.test.tsx | 55 ++++++++ .../AlertsResources/AlertsRegionFilter.tsx | 55 ++++++++ .../AlertsResources/AlertsResources.test.tsx | 78 +++++++++++ .../AlertsResources/AlertsResources.tsx | 123 ++++++++++++++++++ .../AlertsResources/DisplayAlertResources.tsx | 66 ++++++++++ .../Alerts/Utils/AlertResourceUtils.test.ts | 67 ++++++++++ .../Alerts/Utils/AlertResourceUtils.ts | 55 ++++++++ 10 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-11524-upcoming-features-1737011022935.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts diff --git a/packages/manager/.changeset/pr-11524-upcoming-features-1737011022935.md b/packages/manager/.changeset/pr-11524-upcoming-features-1737011022935.md new file mode 100644 index 00000000000..c3517211a6f --- /dev/null +++ b/packages/manager/.changeset/pr-11524-upcoming-features-1737011022935.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add scaffolding for Resources section in Cloud Pulse Alert details page ([#11524](https://github.com/linode/manager/pull/11524)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx index 04641bb7d27..27fa75df515 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; -import { alertFactory, serviceTypesFactory } from 'src/factories/'; +import { + alertFactory, + linodeFactory, + regionFactory, + serviceTypesFactory, +} from 'src/factories/'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { AlertDetail } from './AlertDetail'; @@ -8,10 +13,15 @@ import { AlertDetail } from './AlertDetail'; // Mock Data const alertDetails = alertFactory.build({ service_type: 'linode' }); +const linodes = linodeFactory.buildList(3); +const regions = regionFactory.buildList(3); + // Mock Queries const queryMocks = vi.hoisted(() => ({ useAlertDefinitionQuery: vi.fn(), useCloudPulseServiceTypes: vi.fn(), + useRegionsQuery: vi.fn(), + useResourcesQuery: vi.fn(), })); vi.mock('src/queries/cloudpulse/alerts', () => ({ @@ -26,6 +36,16 @@ vi.mock('src/queries/cloudpulse/services', () => { }; }); +vi.mock('src/queries/cloudpulse/resources', () => ({ + ...vi.importActual('src/queries/cloudpulse/resources'), + useResourcesQuery: queryMocks.useResourcesQuery, +})); + +vi.mock('src/queries/regions/regions', () => ({ + ...vi.importActual('src/queries/regions/regions'), + useRegionsQuery: queryMocks.useRegionsQuery, +})); + // Shared Setup beforeEach(() => { queryMocks.useAlertDefinitionQuery.mockReturnValue({ @@ -37,6 +57,16 @@ beforeEach(() => { data: { data: serviceTypesFactory.buildList(1) }, isFetching: false, }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: false, + isFetching: false, + }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isError: false, + isFetching: false, + }); }); describe('AlertDetail component tests', () => { @@ -82,6 +112,7 @@ describe('AlertDetail component tests', () => { // validate overview is present with its couple of properties (values will be validated in its own components test) expect(getByText('Overview')).toBeInTheDocument(); expect(getByText('Criteria')).toBeInTheDocument(); // validate if criteria is present + expect(getByText('Resources')).toBeInTheDocument(); // validate if resources is present expect(getByText('Name:')).toBeInTheDocument(); expect(getByText('Description:')).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index b1212d699b4..3b81a48bdf2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -9,6 +9,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; +import { AlertResources } from '../AlertsResources/AlertsResources'; import { getAlertBoxStyles } from '../Utils/utils'; import { AlertDetailCriteria } from './AlertDetailCriteria'; import { AlertDetailOverview } from './AlertDetailOverview'; @@ -88,7 +89,7 @@ export const AlertDetail = () => { ); } - // TODO: The criteria, resources details for alerts will be added by consuming the results of useAlertDefinitionQuery call in the coming PR's + const { entity_ids: entityIds } = alertDetails; return ( <> @@ -112,6 +113,17 @@ export const AlertDetail = () => { + + + ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx new file mode 100644 index 00000000000..34651adcc73 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.test.tsx @@ -0,0 +1,55 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { regionFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertsRegionFilter } from './AlertsRegionFilter'; + +describe('AlertsRegionFilter component tests', () => { + const mockRegions = regionFactory.buildList(3); + + it('should render the AlertsRegionFilter with required options', async () => { + const mockHandleSelectionChange = vi.fn(); + const { getByRole, getByTestId, queryByTestId } = renderWithTheme( + + ); + await userEvent.click(getByRole('button', { name: 'Open' })); + expect(getByTestId(mockRegions[0].id)).toBeInTheDocument(); + // select an option + await userEvent.click(getByTestId(mockRegions[0].id)); + + await userEvent.click(getByRole('button', { name: 'Close' })); + expect(mockHandleSelectionChange).toHaveBeenCalledWith([mockRegions[0].id]); + // validate the option is selected + expect(queryByTestId(mockRegions[0].id)).toBeInTheDocument(); + // validate other options are not selected + expect(queryByTestId(mockRegions[1].id)).not.toBeInTheDocument(); + + // select another option + await userEvent.click(getByRole('button', { name: 'Open' })); + expect(getByTestId(mockRegions[1].id)).toBeInTheDocument(); + // select an option + await userEvent.click(getByTestId(mockRegions[1].id)); + + await userEvent.click(getByRole('button', { name: 'Close' })); + // validate both the options are selected + expect(queryByTestId(mockRegions[0].id)).toBeInTheDocument(); + expect(queryByTestId(mockRegions[1].id)).toBeInTheDocument(); + expect(mockHandleSelectionChange).toHaveBeenCalledWith([ + mockRegions[0].id, + mockRegions[1].id, + ]); + }); + + it('should render the AlertsRegionFilter with empty options', async () => { + const { getByRole, getByText } = renderWithTheme( + + ); + await userEvent.click(getByRole('button', { name: 'Open' })); // indicates there is a drop down + expect(getByText('No results')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx new file mode 100644 index 00000000000..18058cf0595 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; + +import type { Region } from '@linode/api-v4'; + +export interface AlertsRegionProps { + /** + * Callback for publishing the IDs of the selected regions. + */ + handleSelectionChange: (regions: string[]) => void; + /** + * The regions to be displayed according to the resources associated with alerts + */ + regionOptions: Region[]; +} + +export const AlertsRegionFilter = React.memo((props: AlertsRegionProps) => { + const { handleSelectionChange, regionOptions } = props; + + const [selectedRegion, setSelectedRegion] = React.useState([]); + + const handleRegionChange = React.useCallback( + (regionIds: string[]) => { + handleSelectionChange( + regionIds.length ? regionIds : regionOptions.map(({ id }) => id) // If no regions are selected, include all region IDs + ); + setSelectedRegion( + regionOptions.filter((region) => regionIds.includes(region.id)) // Update the state with the regions matching the selected IDs + ); + }, + [handleSelectionChange, regionOptions] + ); + return ( + region.id)} + /> + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx new file mode 100644 index 00000000000..684c804eff9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { linodeFactory, regionFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertResources } from './AlertsResources'; + +vi.mock('src/queries/cloudpulse/resources', () => ({ + ...vi.importActual('src/queries/cloudpulse/resources'), + useResourcesQuery: queryMocks.useResourcesQuery, +})); + +vi.mock('src/queries/regions/regions', () => ({ + ...vi.importActual('src/queries/regions/regions'), + useRegionsQuery: queryMocks.useRegionsQuery, +})); + +const queryMocks = vi.hoisted(() => ({ + useRegionsQuery: vi.fn(), + useResourcesQuery: vi.fn(), +})); + +const regions = regionFactory.buildList(3); + +const linodes = linodeFactory.buildList(3); + +const searchPlaceholder = 'Search for a Region or Resource'; +const regionPlaceholder = 'Select Regions'; + +beforeEach(() => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: false, + isFetching: false, + }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isError: false, + isFetching: false, + }); +}); + +describe('AlertResources component tests', () => { + it('should render search input, region filter', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText(searchPlaceholder)).toBeInTheDocument(); + expect(getByText(regionPlaceholder)).toBeInTheDocument(); + }); + it('should render circle progress if api calls are in fetching state', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: false, + isFetching: true, + }); + const { getByTestId, queryByText } = renderWithTheme( + + ); + expect(getByTestId('circle-progress')).toBeInTheDocument(); + expect(queryByText(searchPlaceholder)).not.toBeInTheDocument(); + expect(queryByText(regionPlaceholder)).not.toBeInTheDocument(); + }); + + it('should render error state if api call fails', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodes, + isError: true, + isFetching: false, + }); + const { getByText } = renderWithTheme( + + ); + expect( + getByText('Table data is unavailable. Please try again later.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx new file mode 100644 index 00000000000..4ca342bbda8 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -0,0 +1,123 @@ +import { CircleProgress, Stack, Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import { + getRegionOptions, + getRegionsIdRegionMap, +} from '../Utils/AlertResourceUtils'; +import { AlertsRegionFilter } from './AlertsRegionFilter'; +import { DisplayAlertResources } from './DisplayAlertResources'; + +import type { Region } from '@linode/api-v4'; + +export interface AlertResourcesProp { + /** + * The label of the alert to be displayed + */ + alertLabel?: string; + + /** + * The set of resource ids associated with the alerts, that needs to be displayed + */ + alertResourceIds: string[]; + + /** + * The service type associated with the alerts like DBaaS, Linode etc., + */ + serviceType: string; +} + +export const AlertResources = React.memo((props: AlertResourcesProp) => { + const { alertLabel, alertResourceIds, serviceType } = props; + const [searchText, setSearchText] = React.useState(); + + const [, setFilteredRegions] = React.useState(); + + const { + data: regions, + isError: isRegionsError, + isFetching: isRegionsFetching, + } = useRegionsQuery(); + + const { + data: resources, + isError: isResourcesError, + isFetching: isResourcesFetching, + } = useResourcesQuery( + Boolean(serviceType), + serviceType, + {}, + serviceType === 'dbaas' ? { platform: 'rdbms-default' } : {} + ); + + // A map linking region IDs to their corresponding region objects, used for quick lookup when displaying data in the table. + const regionsIdToRegionMap: Map = React.useMemo(() => { + return getRegionsIdRegionMap(regions); + }, [regions]); + + // Derived list of regions associated with the provided resource IDs, filtered based on available data. + const regionOptions: Region[] = React.useMemo(() => { + return getRegionOptions({ + data: resources, + regionsIdToRegionMap, + resourceIds: alertResourceIds, + }); + }, [resources, alertResourceIds, regionsIdToRegionMap]); + + const handleSearchTextChange = (searchText: string) => { + setSearchText(searchText); + }; + + const handleFilteredRegionsChange = (selectedRegions: string[]) => { + setFilteredRegions(selectedRegions); + }; + + const titleRef = React.useRef(null); // Reference to the component title, used for scrolling to the title when the table's page size or page number changes. + + if (isResourcesFetching || isRegionsFetching) { + return ; + } + + const isDataLoadingError = isRegionsError || isResourcesError; + + return ( + + + {alertLabel || 'Resources'} + {/* It can be either the passed alert label or just Resources */} + + + + + + + + + + + + + + + + + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx new file mode 100644 index 00000000000..f8f3bee12e0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableSortCell } from 'src/components/TableSortCell'; + +export interface DisplayAlertResourceProp { + /** + * A flag indicating if there was an error loading the data. If true, the error message + * (specified by `errorText`) will be displayed in the table. + */ + isDataLoadingError?: boolean; +} + +export const DisplayAlertResources = React.memo( + (props: DisplayAlertResourceProp) => { + const { isDataLoadingError } = props; + return ( + + + + {}} // TODO: Implement sorting logic for this column. + label="label" + > + Resource + + {}} // TODO: Implement sorting logic for this column. + label="region" + > + Region + + + + + {isDataLoadingError && ( + + )} + {!isDataLoadingError && ( + // Placeholder cell to maintain table structure before body content is implemented. + + + {/* TODO: Populate the table body with resource data and implement sorting and pagination in future PRs. */} + + )} + +
    + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts new file mode 100644 index 00000000000..905094b1b36 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts @@ -0,0 +1,67 @@ +import { regionFactory } from 'src/factories'; + +import { getRegionOptions, getRegionsIdRegionMap } from './AlertResourceUtils'; + +import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; + +describe('getRegionsIdLabelMap', () => { + it('should return a proper map for given regions', () => { + const regions = regionFactory.buildList(10); + const result = getRegionsIdRegionMap(regions); + // check for a key + expect(result.has(regions[0].id)).toBe(true); + // check for value to match the region object + expect(result.get(regions[0].id)).toBe(regions[0]); + }); + it('should return 0 if regions is passed as undefined', () => { + const result = getRegionsIdRegionMap(undefined); + // if regions passed undefined, it should return an empty map + expect(result.size).toBe(0); + }); +}); + +describe('getRegionOptions', () => { + const regions = regionFactory.buildList(10); + const regionsIdToLabelMap = getRegionsIdRegionMap(regions); + const data: CloudPulseResources[] = [ + { id: '1', label: 'Test', region: regions[0].id }, + { id: '2', label: 'Test2', region: regions[1].id }, + { id: '3', label: 'Test3', region: regions[2].id }, + ]; + it('should return correct region objects for given resourceIds', () => { + const result = getRegionOptions({ + data, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['1', '2'], + }); + // Valid case + expect(result.length).toBe(2); + }); + + it('should return an empty region options if data is not passed', () => { + // Case with no data + const result = getRegionOptions({ + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['1', '2'], + }); + expect(result.length).toBe(0); + }); + + it('should return an empty region options if there is no matching resource ids', () => { + const result = getRegionOptions({ + data, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['101'], + }); + expect(result.length).toBe(0); + }); + + it('should return unique regions even if resourceIds contains duplicates', () => { + const result = getRegionOptions({ + data, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: ['1', '1', '2', '2'], // Duplicate IDs + }); + expect(result.length).toBe(2); // Should still return unique regions + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts new file mode 100644 index 00000000000..d3c2b9bc0e4 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts @@ -0,0 +1,55 @@ +import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; +import type { Region } from '@linode/api-v4'; + +interface FilterResourceProps { + /** + * The data to be filtered + */ + data?: CloudPulseResources[]; + /** + * The map that holds the id of the region to Region object, helps in building the alert resources + */ + regionsIdToRegionMap: Map; + /** + * The resources associated with the alerts + */ + resourceIds: string[]; +} + +/** + * @param regions The list of regions + * @returns A Map of region ID to Region object. Returns an empty Map if regions is undefined. + */ +export const getRegionsIdRegionMap = ( + regions: Region[] | undefined +): Map => { + if (!regions) { + return new Map(); + } + return new Map(regions.map((region) => [region.id, region])); +}; + +/** + * @param filterProps The props required to get the region options and the filtered resources + * @returns Array of unique regions associated with the resource ids of the alert + */ +export const getRegionOptions = ( + filterProps: FilterResourceProps +): Region[] => { + const { data, regionsIdToRegionMap, resourceIds } = filterProps; + if (!data || !resourceIds.length || !regionsIdToRegionMap.size) { + return []; + } + const uniqueRegions = new Set(); + data.forEach(({ id, region }) => { + if (resourceIds.includes(String(id))) { + const regionObject = region + ? regionsIdToRegionMap.get(region) + : undefined; + if (regionObject) { + uniqueRegions.add(regionObject); + } + } + }); + return Array.from(uniqueRegions); +}; From 35f2726dd280796d7d585fcda17c1fd17d9aadcc Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 17 Jan 2025 21:37:37 -0500 Subject: [PATCH 30/42] deps: [M3-9135] - Upgrade to TypeScript v5.7 (#11531) * update typescript to v5.7 * update typescript to v5.7 part 2 * revert extra change * revert extra change * revert extra change * revert extra change * add changeset --------- Co-authored-by: Banks Nussman --- package.json | 2 +- .../pr-11531-tech-stories-1737051309899.md | 5 +++ .../src/components/Uploaders/reducer.test.ts | 3 ++ yarn.lock | 39 ++++--------------- 4 files changed, 16 insertions(+), 33 deletions(-) create mode 100644 packages/manager/.changeset/pr-11531-tech-stories-1737051309899.md diff --git a/package.json b/package.json index 6b603d00873..a23e6466eff 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "Apache-2.0", "devDependencies": { "husky": "^9.1.6", - "typescript": "^5.5.4", + "typescript": "^5.7.3", "vitest": "^2.1.1" }, "scripts": { diff --git a/packages/manager/.changeset/pr-11531-tech-stories-1737051309899.md b/packages/manager/.changeset/pr-11531-tech-stories-1737051309899.md new file mode 100644 index 00000000000..e31454ae3b9 --- /dev/null +++ b/packages/manager/.changeset/pr-11531-tech-stories-1737051309899.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update to TypeScript v5.7 ([#11531](https://github.com/linode/manager/pull/11531)) diff --git a/packages/manager/src/components/Uploaders/reducer.test.ts b/packages/manager/src/components/Uploaders/reducer.test.ts index 3bd3102b849..78571ad5c34 100644 --- a/packages/manager/src/components/Uploaders/reducer.test.ts +++ b/packages/manager/src/components/Uploaders/reducer.test.ts @@ -15,6 +15,7 @@ describe('reducer', () => { const file1: File = { arrayBuffer: vi.fn(), + bytes: async () => new Uint8Array(), lastModified: 0, name: 'my-file1', size: 0, @@ -24,8 +25,10 @@ describe('reducer', () => { type: '', webkitRelativePath: '', }; + const file2: File = { arrayBuffer: vi.fn(), + bytes: async () => new Uint8Array(), lastModified: 0, name: 'my-file2', size: 0, diff --git a/yarn.lock b/yarn.lock index 2a2b9a744d6..5d5e43a404b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8989,7 +8989,7 @@ string-argv@~0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9007,15 +9007,6 @@ string-width@^3.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -9096,7 +9087,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9110,13 +9101,6 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9611,10 +9595,10 @@ typescript@^4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@^5.5.4: - version "5.6.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" - integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== +typescript@^5.7.3: + version "5.7.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" + integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== ua-parser-js@^0.7.30: version "0.7.39" @@ -10027,7 +10011,7 @@ word-wrap@^1.2.5, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10045,15 +10029,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 9b2513a9854b11aaba71e1b16d94903605f523a9 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:58:09 -0500 Subject: [PATCH 31/42] refactor: [M3-6919] - replace remaining react-select instances & types in Linodes Feature (#11509) * Type improvements and cleanup * ip sharing * select story fix * feedback @bnussman-akamai * Added changeset: Replace remaining react-select instances & types in Linodes Feature * feedback @dwiley-akamai --- .../pr-11509-tech-stories-1736971677397.md | 5 ++ .../component/components/select.spec.tsx | 16 +++--- .../Linodes/DiskSelect/DiskSelect.tsx | 57 ------------------- .../LinodeNetworking/AddIPDrawer.tsx | 8 ++- .../LinodeNetworking/IPSharing.tsx | 23 +++----- .../LinodeRebuild/RebuildFromImage.test.tsx | 1 - .../LinodeSettings/InterfaceSelect.tsx | 4 +- .../LinodeSettings/KernelSelect.test.tsx | 2 - .../LinodeSettingsPasswordPanel.tsx | 20 ++++--- .../LinodeSummary/LinodeSummary.tsx | 18 +++--- .../src/features/Users/UserPermissions.tsx | 4 +- .../src/components/Select/Select.stories.tsx | 14 +++-- packages/ui/src/components/Select/Select.tsx | 54 +++++++++++------- 13 files changed, 96 insertions(+), 130 deletions(-) create mode 100644 packages/manager/.changeset/pr-11509-tech-stories-1736971677397.md delete mode 100644 packages/manager/src/features/Linodes/DiskSelect/DiskSelect.tsx diff --git a/packages/manager/.changeset/pr-11509-tech-stories-1736971677397.md b/packages/manager/.changeset/pr-11509-tech-stories-1736971677397.md new file mode 100644 index 00000000000..3b7677e5774 --- /dev/null +++ b/packages/manager/.changeset/pr-11509-tech-stories-1736971677397.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace remaining react-select instances & types in Linodes Feature ([#11509](https://github.com/linode/manager/pull/11509)) diff --git a/packages/manager/cypress/component/components/select.spec.tsx b/packages/manager/cypress/component/components/select.spec.tsx index bf49abb3415..71d0c2c5c97 100644 --- a/packages/manager/cypress/component/components/select.spec.tsx +++ b/packages/manager/cypress/component/components/select.spec.tsx @@ -4,7 +4,7 @@ import { ui } from 'support/ui'; import { createSpy } from 'support/util/components'; import { componentTests } from 'support/util/components'; -import type { SelectOptionType, SelectProps } from '@linode/ui'; +import type { SelectOption, SelectProps } from '@linode/ui'; const options = [ { label: 'Option 1', value: 'option-1' }, @@ -260,7 +260,7 @@ componentTests('Select', (mount) => { }); }); - const defaultProps: SelectProps = { + const defaultProps = { label: 'My Select', onChange: () => {}, options, @@ -268,10 +268,10 @@ componentTests('Select', (mount) => { }; describe('Logic', () => { - const WrappedSelect = (props: Partial) => { - const [value, setValue] = React.useState< - SelectOptionType | null | undefined - >(null); + const WrappedSelect = (props: Partial>) => { + const [value, setValue] = React.useState( + null + ); return ( <> @@ -280,7 +280,9 @@ componentTests('Select', (mount) => { onChange={(_, newValue) => setValue({ label: newValue?.label ?? '', - value: newValue?.value.replace(' ', '-').toLowerCase() ?? '', + value: + newValue?.value.toString().replace(' ', '-').toLowerCase() ?? + '', }) } textFieldProps={{ diff --git a/packages/manager/src/features/Linodes/DiskSelect/DiskSelect.tsx b/packages/manager/src/features/Linodes/DiskSelect/DiskSelect.tsx deleted file mode 100644 index 8d66100fb2f..00000000000 --- a/packages/manager/src/features/Linodes/DiskSelect/DiskSelect.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Disk } from '@linode/api-v4/lib/linodes'; -import * as React from 'react'; - -import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; -import { RenderGuard } from 'src/components/RenderGuard'; - -interface Props { - disabled?: boolean; - diskError?: string; - disks: Disk[]; - generalError?: string; - handleChange: (disk: null | string) => void; - required?: boolean; - selectedDisk: null | string; -} - -const disksToOptions = (disks: Disk[]): Item[] => { - return disks.map((disk) => ({ label: disk.label, value: String(disk.id) })); -}; - -const diskFromValue = ( - disks: Item[], - diskId: null | string -): Item | null => { - if (!diskId) { - return null; - } - const thisDisk = disks.find((disk) => disk.value === diskId); - return thisDisk ? thisDisk : null; -}; - -export const DiskSelect = RenderGuard((props: Props) => { - const { - disabled, - diskError, - disks, - generalError, - handleChange, - required, - selectedDisk, - } = props; - const options = disksToOptions(disks); - return ( - | null) => - handleChange(newDisk ? newDisk.value : null) - } - disabled={disabled} - errorText={generalError || diskError} - label={'Disk'} - options={options} - placeholder={'Select a Disk'} - required={required} - value={diskFromValue(options, selectedDisk)} - /> - ); -}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index d7a71740e56..caf233dc8cb 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -23,11 +23,15 @@ import { useCreateIPv6RangeMutation } from 'src/queries/networking/networking'; import { ExplainerCopy } from './ExplainerCopy'; import type { IPv6Prefix } from '@linode/api-v4/lib/networking'; -import type { Item } from 'src/components/EnhancedSelect/Select'; export type IPType = 'v4Private' | 'v4Public'; -const ipOptions: Item[] = [ +type IPOption = { + label: string; + value: IPType; +}; + +const ipOptions: IPOption[] = [ { label: 'Public', value: 'v4Public' }, { label: 'Private', value: 'v4Private' }, ]; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx index 0a775108d92..25660e3fe52 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx @@ -3,17 +3,17 @@ import { CircleProgress, Divider, Notice, + Select, TextField, Typography, } from '@linode/ui'; -import { styled, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { remove, uniq, update } from 'ramda'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Dialog } from 'src/components/Dialog/Dialog'; -import Select from 'src/components/EnhancedSelect/Select'; import { Link } from 'src/components/Link'; import { API_MAX_PAGE_SIZE } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; @@ -32,7 +32,7 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import type { Linode } from '@linode/api-v4/lib/linodes'; import type { IPRangeInformation } from '@linode/api-v4/lib/networking'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { Item } from 'src/components/EnhancedSelect/Select'; +import type { SelectOption } from '@linode/ui'; interface Props { linodeId: number; @@ -165,7 +165,7 @@ const IPSharingPanel = (props: Props) => { } }, [ips, ranges]); - const onIPSelect = (ipIdx: number, e: Item) => { + const onIPSelect = (ipIdx: number, e: SelectOption) => { setIpsToShare((currentIps) => { return ipIdx >= currentIps.length ? [...currentIps, e.value] @@ -470,7 +470,7 @@ export const IPRow: React.FC = React.memo((props) => { interface SharingRowProps extends RowProps { getRemainingChoices: (ip: string | undefined) => string[]; handleDelete?: (idx: number) => void; - handleSelect: (idx: number, selected: Item) => void; + handleSelect: (idx: number, selected: SelectOption) => void; idx: number; labels: Record; readOnly: boolean; @@ -505,7 +505,7 @@ export const IPSharingRow: React.FC = React.memo((props) => { - = React.memo((props) => { }} disabled={readOnly} hideLabel - inputId={`ip-select-${idx}`} - isClearable={false} label="Select an IP" - onChange={(selected: Item) => handleSelect(idx, selected)} + onChange={(_, selected) => handleSelect(idx, selected)} options={ipList} - overflowPortal placeholder="Select an IP" + sx={{ marginTop: 0, width: '100%' }} value={selectedIP} /> @@ -551,9 +549,4 @@ export const IPSharingRow: React.FC = React.memo((props) => { ); }); -const StyledSelect = styled(Select, { label: 'StyledSelect' })({ - marginTop: 0, - width: '100%', -}); - export default IPSharingPanel; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx index ecdc17ca5f1..66b01afb0b3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx @@ -7,7 +7,6 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { RebuildFromImage } from './RebuildFromImage'; vi.mock('src/utilities/scrollErrorIntoView'); -vi.mock('src/components/EnhancedSelect/Select'); const props = { disabled: false, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 5dc0147986b..08262e4b789 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -19,7 +19,7 @@ import type { InterfacePayload, InterfacePurpose, } from '@linode/api-v4/lib/linodes/types'; -import type { Item } from 'src/components/EnhancedSelect/Select'; +import type { SelectOption } from '@linode/ui'; import type { ExtendedIP } from 'src/utilities/ipUtils'; interface InterfaceErrors extends VPCInterfaceErrors, OtherInterfaceErrors {} @@ -93,7 +93,7 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { const [newVlan, setNewVlan] = React.useState(''); - const purposeOptions: Item[] = [ + const purposeOptions: SelectOption[] = [ { label: 'Public Internet', value: 'public', diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.test.tsx index 9079ee9a481..c04ff561f48 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/KernelSelect.test.tsx @@ -28,8 +28,6 @@ const kernels = [ kernelFactory.build({ id: 'linode/direct-disk', label: 'Direct Disk' }), ]; -vi.mock('src/components/EnhancedSelect/Select'); - describe('Kernel Select component', () => { it('should render a select with the correct number of options', async () => { const props: KernelSelectProps = { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx index 16c18436ec8..551cbe9d7a2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx @@ -1,10 +1,9 @@ -import { Accordion, Notice } from '@linode/ui'; +import { Accordion, Notice, Select } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import EnhancedSelect from 'src/components/EnhancedSelect/Select'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useAllLinodeDisksQuery, @@ -89,7 +88,7 @@ export const LinodeSettingsPasswordPanel = (props: Props) => { ?.filter((d) => d.filesystem !== 'swap') .map((d) => ({ label: d.label, value: d.id })); - // If there is only one selectable disk, select it automaticly + // If there is only one selectable disk, select it automatically React.useEffect(() => { if (diskOptions !== undefined && diskOptions.length === 1) { setSelectedDiskId(diskOptions[0].value); @@ -121,17 +120,20 @@ export const LinodeSettingsPasswordPanel = (props: Props) => {
    {generalError && } {!isBareMetalInstance ? ( - + setSelectedDiskId(Number(item?.value) ?? null) + } + value={ + diskOptions?.find((item) => item.value === selectedDiskId) ?? null + } data-qa-select-linode disabled={isReadOnly} errorText={disksError?.[0].reason} - isClearable={false} - isLoading={disksLoading} label="Disk" - onChange={(item) => setSelectedDiskId(item.value)} - options={diskOptions} + loading={disksLoading} + options={diskOptions ?? []} placeholder="Select a Disk" - value={diskOptions?.find((item) => item.value === selectedDiskId)} /> ) : null} }> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index 2d6c04a1538..250858ed7e2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -28,12 +28,12 @@ import { NetworkGraphs } from './NetworkGraphs'; import { StatsPanel } from './StatsPanel'; import type { ChartProps } from './NetworkGraphs'; +import type { SelectOption } from '@linode/ui'; import type { CPUTimeData, DiskIOTimeData, Point, } from 'src/components/AreaChart/types'; -import type { Item } from 'src/components/EnhancedSelect/Select'; setUpCharts(); @@ -83,7 +83,7 @@ const LinodeSummary = (props: Props) => { statsErrorString ); - const handleChartRangeChange = (e: Item) => { + const handleChartRangeChange = (e: SelectOption) => { setRangeSelection(e.value); }; @@ -237,21 +237,21 @@ const LinodeSummary = (props: Props) => { handleChartRangeChange(value)} options={options} + sx={{ mt: 1, width: 150 }} /> - + { - + { }); }; - setAllEntitiesTo = (e: SelectOptionType | null | undefined) => { + setAllEntitiesTo = (e: SelectOption | null | undefined) => { const value = e?.value === 'null' ? null : e?.value; this.entityPerms.map((entity: GrantType) => this.entitySetAllTo(entity, value as GrantLevel)() diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx index a0a19ea055a..065b8ee8116 100644 --- a/packages/ui/src/components/Select/Select.stories.tsx +++ b/packages/ui/src/components/Select/Select.stories.tsx @@ -4,18 +4,18 @@ import { Box } from '../Box'; import { Typography } from '../Typography'; import { Select } from './Select'; -import type { SelectProps } from './Select'; +import type { SelectOption, SelectProps } from './Select'; import type { Meta, StoryObj } from '@storybook/react'; -const meta: Meta = { +const meta: Meta> = { component: Select, decorators: [(Story) => {Story()}], title: 'Components/Selects/Select', }; -type Story = StoryObj; +type Story = StoryObj>; -const defaultArgs: SelectProps = { +const defaultArgs: SelectProps = { clearable: false, creatable: false, hideLabel: false, @@ -52,7 +52,11 @@ export const Creatable: Story = { onChange={(_, newValue) => setValue({ label: newValue?.label ?? '', - value: newValue?.value.replace(' ', '-').toLowerCase() ?? '', + value: + newValue?.value + .toString() + .replaceAll(' ', '-') + .toLowerCase() ?? '', }) } textFieldProps={{ diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx index f120c57aeee..e178ba3c898 100644 --- a/packages/ui/src/components/Select/Select.tsx +++ b/packages/ui/src/components/Select/Select.tsx @@ -7,12 +7,22 @@ import { ListItem } from '../ListItem'; import { TextField } from '../TextField'; import type { EnhancedAutocompleteProps } from '../Autocomplete'; +import type { AutocompleteValue, SxProps } from '@mui/material'; +import type { Theme } from '@mui/material/styles'; -export type SelectOptionType = { +type Option = { label: string; - value: string; + value: T; }; -interface InternalOptionType extends SelectOptionType { + +export type SelectOption< + T = number | string, + Nullable extends boolean = false +> = Nullable extends true + ? AutocompleteValue, false, false, false> + : Option; + +interface InternalOptionType extends SelectOption { /** * Whether the option is a "create" option. * @@ -26,9 +36,11 @@ interface InternalOptionType extends SelectOptionType { */ noOptions?: boolean; } -export interface SelectProps + +export interface SelectProps extends Pick< - EnhancedAutocompleteProps, + EnhancedAutocompleteProps, + | 'disabled' | 'errorText' | 'helperText' | 'id' @@ -68,10 +80,7 @@ export interface SelectProps /** * The callback function that is invoked when the value changes. */ - onChange?: ( - _event: React.SyntheticEvent, - _value: SelectProps['value'] - ) => void; + onChange?: (_event: React.SyntheticEvent, _value: T) => void; /** * Whether the select is required. * @@ -84,6 +93,10 @@ export interface SelectProps * @default false */ searchable?: boolean; + /** + * The style overrides for the select. + */ + sx?: SxProps; } /** @@ -94,7 +107,9 @@ export interface SelectProps * * For any other use-cases, use the Autocomplete component directly. */ -export const Select = (props: SelectProps) => { +export const Select = ( + props: SelectProps +) => { const { clearable = false, creatable = false, @@ -113,21 +128,21 @@ export const Select = (props: SelectProps) => { const handleChange = ( event: React.SyntheticEvent, - value: SelectOptionType | null | string + value: SelectOption | null | string ) => { if (creatable && typeof value === 'string') { onChange?.(event, { label: value, value, - }); + } as T); } else if (value && typeof value === 'object' && 'label' in value) { const { label, value: optionValue } = value; onChange?.(event, { label, value: optionValue, - }); + } as T); } else { - onChange?.(event, null); + onChange?.(event, (null as unknown) as T); } }; @@ -137,7 +152,7 @@ export const Select = (props: SelectProps) => { ); return ( - + {...rest} isOptionEqualToValue={(option, value) => { if (!option || !value) { @@ -178,6 +193,7 @@ export const Select = (props: SelectProps) => { label={label} placeholder={props.placeholder} required={props.required} + sx={sx} /> )} renderOption={(props, option: InternalOptionType) => { @@ -219,7 +235,7 @@ export const Select = (props: SelectProps) => { disableClearable={!clearable} forcePopupIcon freeSolo={creatable} - getOptionDisabled={(option: SelectOptionType) => option.value === ''} + getOptionDisabled={(option: SelectOption) => option.value === ''} label={label} noOptionsText={noOptionsText} onChange={handleChange} @@ -234,7 +250,7 @@ interface GetOptionsProps { /** * Whether the select can create a new option. */ - creatable: SelectProps['creatable']; + creatable: boolean; /** * The input value. */ @@ -265,13 +281,13 @@ const getOptions = ({ creatable, inputValue, options }: GetOptionsProps) => { const matchingOptions = options.filter( (opt) => opt.label.toLowerCase().includes(inputValue.toLowerCase()) || - opt.value.toLowerCase().includes(inputValue.toLowerCase()) + opt.value.toString().toLowerCase().includes(inputValue.toLowerCase()) ); const exactMatch = matchingOptions.some( (opt) => opt.label.toLowerCase() === inputValue.toLowerCase() || - opt.value.toLowerCase() === inputValue.toLowerCase() + opt.value.toString().toLowerCase() === inputValue.toLowerCase() ); // If there's an exact match, don't show is as a create option From a7ea42fb9600ce3b28848ed8b2d94d56c4b3ed9e Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Tue, 21 Jan 2025 11:00:43 -0500 Subject: [PATCH 32/42] =?UTF-8?q?change:=20[M3-9132]=20=E2=80=93=20Revise?= =?UTF-8?q?=20description=20for=20"Disk=20Encryption"=20section=20in=20Lin?= =?UTF-8?q?ode=20Create=20flow=20(#11536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.changeset/pr-11536-changed-1737152603420.md | 5 +++++ .../create-linode-with-disk-encryption.spec.ts | 2 +- .../src/components/Encryption/Encryption.test.tsx | 8 ++------ .../manager/src/components/Encryption/Encryption.tsx | 6 ++---- .../manager/src/components/Encryption/constants.tsx | 11 ++++++++--- 5 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-11536-changed-1737152603420.md diff --git a/packages/manager/.changeset/pr-11536-changed-1737152603420.md b/packages/manager/.changeset/pr-11536-changed-1737152603420.md new file mode 100644 index 00000000000..2ce268906d4 --- /dev/null +++ b/packages/manager/.changeset/pr-11536-changed-1737152603420.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Revise Disk Encryption description copy in Linode Create flow ([#11536](https://github.com/linode/manager/pull/11536)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts index cf8707c1ec9..ba4b950c8ea 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -7,7 +7,7 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { checkboxTestId, headerTestId, -} from 'src/components/Encryption/Encryption'; +} from 'src/components/Encryption/constants'; describe('Create Linode with Disk Encryption', () => { it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { diff --git a/packages/manager/src/components/Encryption/Encryption.test.tsx b/packages/manager/src/components/Encryption/Encryption.test.tsx index 1a4750c0846..3b65e7dba5d 100644 --- a/packages/manager/src/components/Encryption/Encryption.test.tsx +++ b/packages/manager/src/components/Encryption/Encryption.test.tsx @@ -2,12 +2,8 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { - Encryption, - checkboxTestId, - descriptionTestId, - headerTestId, -} from './Encryption'; +import { checkboxTestId, descriptionTestId, headerTestId } from './constants'; +import { Encryption } from './Encryption'; describe('DiskEncryption', () => { it('should render a header', () => { diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx index 60fb435cc07..1b90722ce39 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -2,6 +2,8 @@ import { Box, Checkbox, Notice, Typography } from '@linode/ui'; import { List, ListItem } from '@mui/material'; import * as React from 'react'; +import { checkboxTestId, descriptionTestId, headerTestId } from './constants'; + export interface EncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; @@ -13,10 +15,6 @@ export interface EncryptionProps { onChange: (checked: boolean) => void; } -export const headerTestId = 'encryption-header'; -export const descriptionTestId = 'encryption-description'; -export const checkboxTestId = 'encrypt-entity-checkbox'; - export const Encryption = (props: EncryptionProps) => { const { descriptionCopy, diff --git a/packages/manager/src/components/Encryption/constants.tsx b/packages/manager/src/components/Encryption/constants.tsx index 7224e491364..0cb07201c56 100644 --- a/packages/manager/src/components/Encryption/constants.tsx +++ b/packages/manager/src/components/Encryption/constants.tsx @@ -2,15 +2,20 @@ import React from 'react'; import { Link } from 'src/components/Link'; +/* Test IDs */ +export const headerTestId = 'encryption-header'; +export const descriptionTestId = 'encryption-description'; +export const checkboxTestId = 'encrypt-entity-checkbox'; + /* Disk Encryption constants */ const DISK_ENCRYPTION_GUIDE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/local-disk-encryption'; export const DISK_ENCRYPTION_GENERAL_DESCRIPTION = ( <> - Secure this Linode using data at rest encryption. Data center systems take - care of encrypting and decrypting for you. After the Linode is created, use - Rebuild to enable or disable this feature.{' '} + Secure this Linode with data-at-rest encryption. Data center systems handle + encryption automatically for you. After the Linode is created, use Rebuild + to enable or disable encryption.{' '} Learn more. ); From e51862d7a30de7cb536397e064df706002cf019e Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Wed, 22 Jan 2025 00:19:54 +0530 Subject: [PATCH 33/42] upcoming: [DI - 22836] - Added AddNotificationChannel component (#11511) * upcoming: [22836] - Added Notification Channel Drawer component with relevant types,schemas * upcoming: [DI-22836] - adding changesets * upcoming: [DI-22836] - Renaming the type from ChannelTypes to ChannelType * upcoming: [DI-22836] - Fixing failing test * upcoming :[DI-22836] - Review changes: removing conditional rendering of Drawer component * upcoming: [DI-22836] - Review changes * upcoming: [DI-22836] - Removed the To label as per review comment --- .../pr-11511-added-1736761634591.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 76 ++++++++ .../pr-11511-added-1736761595455.md | 5 + .../src/factories/cloudpulse/channels.ts | 32 ++++ packages/manager/src/factories/index.ts | 1 + .../CreateAlert/CreateAlertDefinition.tsx | 60 +++++- .../AddNotificationChannel.test.tsx | 121 ++++++++++++ .../AddNotificationChannel.tsx | 181 ++++++++++++++++++ .../CloudPulse/Alerts/CreateAlert/schemas.ts | 7 + .../CloudPulse/Alerts/CreateAlert/types.ts | 6 + .../features/CloudPulse/Alerts/constants.ts | 16 ++ 11 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11511-added-1736761634591.md create mode 100644 packages/manager/.changeset/pr-11511-added-1736761595455.md create mode 100644 packages/manager/src/factories/cloudpulse/channels.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx diff --git a/packages/api-v4/.changeset/pr-11511-added-1736761634591.md b/packages/api-v4/.changeset/pr-11511-added-1736761634591.md new file mode 100644 index 00000000000..01fa191f583 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11511-added-1736761634591.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Notification Channel related types to cloudpulse/alerts.ts ([#11511](https://github.com/linode/manager/pull/11511)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 7fb5db6ce6e..1fa7b63d280 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -20,6 +20,13 @@ export type MetricUnitType = | 'KB' | 'MB' | 'GB'; +export type NotificationStatus = 'Enabled' | 'Disabled'; +export type ChannelType = 'email' | 'slack' | 'pagerduty' | 'webhook'; +export type AlertNotificationType = 'default' | 'custom'; +type AlertNotificationEmail = 'email'; +type AlertNotificationSlack = 'slack'; +type AlertNotificationPagerDuty = 'pagerduty'; +type AlertNotificationWebHook = 'webhook'; export interface Dashboard { id: number; label: string; @@ -218,3 +225,72 @@ export interface Alert { created: string; updated: string; } + +interface NotificationChannelAlerts { + id: number; + label: string; + url: string; + type: 'alerts-definitions'; +} +interface NotificationChannelBase { + id: number; + label: string; + channel_type: ChannelType; + type: AlertNotificationType; + status: NotificationStatus; + alerts: NotificationChannelAlerts[]; + created_by: string; + updated_by: string; + created_at: string; + updated_at: string; +} + +interface NotificationChannelEmail extends NotificationChannelBase { + channel_type: AlertNotificationEmail; + content: { + email: { + email_addresses: string[]; + subject: string; + message: string; + }; + }; +} + +interface NotificationChannelSlack extends NotificationChannelBase { + channel_type: AlertNotificationSlack; + content: { + slack: { + slack_webhook_url: string; + slack_channel: string; + message: string; + }; + }; +} + +interface NotificationChannelPagerDuty extends NotificationChannelBase { + channel_type: AlertNotificationPagerDuty; + content: { + pagerduty: { + service_api_key: string; + attributes: string[]; + description: string; + }; + }; +} +interface NotificationChannelWebHook extends NotificationChannelBase { + channel_type: AlertNotificationWebHook; + content: { + webhook: { + webhook_url: string; + http_headers: { + header_key: string; + header_value: string; + }[]; + }; + }; +} +export type NotificationChannel = + | NotificationChannelEmail + | NotificationChannelSlack + | NotificationChannelWebHook + | NotificationChannelPagerDuty; diff --git a/packages/manager/.changeset/pr-11511-added-1736761595455.md b/packages/manager/.changeset/pr-11511-added-1736761595455.md new file mode 100644 index 00000000000..0b374f27f76 --- /dev/null +++ b/packages/manager/.changeset/pr-11511-added-1736761595455.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +AddNotificationChannel component with Unit tests with necessary changes for constants, CreateAlertDefinition and other components. ([#11511](https://github.com/linode/manager/pull/11511)) diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts new file mode 100644 index 00000000000..d7560717414 --- /dev/null +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -0,0 +1,32 @@ +import Factory from 'src/factories/factoryProxy'; + +import type { NotificationChannel } from '@linode/api-v4'; + +export const notificationChannelFactory = Factory.Sync.makeFactory( + { + alerts: [ + { + id: Number(Factory.each((i) => i)), + label: String(Factory.each((id) => `Alert-${id}`)), + type: 'alerts-definitions', + url: 'Sample', + }, + ], + channel_type: 'email', + content: { + email: { + email_addresses: ['test@test.com', 'test2@test.com'], + message: 'You have a new Alert', + subject: 'Sample Alert', + }, + }, + created_at: new Date().toISOString(), + created_by: 'user1', + id: Factory.each((i) => i), + label: Factory.each((id) => `Channel-${id}`), + status: 'Enabled', + type: 'custom', + updated_at: new Date().toISOString(), + updated_by: 'user1', + } +); diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index 230144fbb9a..7811496bc26 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -54,6 +54,7 @@ export * from './vpcs'; export * from './dashboards'; export * from './cloudpulse/services'; export * from './cloudpulse/alerts'; +export * from './cloudpulse/channels'; // Convert factory output to our itemsById pattern export const normalizeEntities = (entities: any[]) => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 8df01ae47cc..be961d7b308 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -1,5 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { Paper, TextField, Typography } from '@linode/ui'; +import { Box, Button, Paper, TextField, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; @@ -7,6 +7,8 @@ import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { Drawer } from 'src/components/Drawer'; +import { notificationChannelFactory } from 'src/factories'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; @@ -16,6 +18,7 @@ import { EngineOption } from './GeneralInformation/EngineOption'; import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; import { CloudPulseMultiResourceSelect } from './GeneralInformation/ResourceMultiSelect'; import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; +import { AddNotificationChannel } from './NotificationChannels/AddNotificationChannel'; import { CreateAlertDefinitionFormSchema } from './schemas'; import { filterFormValues } from './utilities'; @@ -78,18 +81,34 @@ export const CreateAlertDefinition = () => { ), }); - const { control, formState, getValues, handleSubmit, setError } = formMethods; + const { + control, + formState, + getValues, + handleSubmit, + setError, + setValue, + } = formMethods; const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: createAlert } = useCreateAlertDefinition( getValues('serviceType')! ); - /** - * The maxScrapeInterval variable will be required for the Trigger Conditions part of the Critieria section. - */ + const notificationChannelWatcher = useWatch({ control, name: 'channel_ids' }); + const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); + + const [openAddNotification, setOpenAddNotification] = React.useState(false); const [maxScrapeInterval, setMaxScrapeInterval] = React.useState(0); - const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); + const onSubmitAddNotification = (notificationId: number) => { + setValue('channel_ids', [...notificationChannelWatcher, notificationId], { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + setOpenAddNotification(false); + }; + const onSubmit = handleSubmit(async (values) => { try { await createAlert(filterFormValues(values)); @@ -111,6 +130,13 @@ export const CreateAlertDefinition = () => { } }); + const onExitNotifications = () => { + setOpenAddNotification(false); + }; + + const onAddNotifications = () => { + setOpenAddNotification(true); + }; return ( @@ -172,6 +198,15 @@ export const CreateAlertDefinition = () => { maxScrapingInterval={maxScrapeInterval} name="trigger_conditions" /> + + + { }} sx={{ display: 'flex', justifyContent: 'flex-end' }} /> + + + diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx new file mode 100644 index 00000000000..4544e3195eb --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.test.tsx @@ -0,0 +1,121 @@ +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { notificationChannelFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { channelTypeOptions } from '../../constants'; +import { AddNotificationChannel } from './AddNotificationChannel'; + +const mockData = [notificationChannelFactory.build()]; + +describe('AddNotificationChannel component', () => { + const user = userEvent.setup(); + it('should render the components', () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + expect(getByText('Channel Settings')).toBeVisible(); + expect(getByLabelText('Type')).toBeVisible(); + expect(getByLabelText('Channel')).toBeVisible(); + }); + + it('should render the type component with happy path and able to select an option', async () => { + const { findByRole, getByTestId } = renderWithTheme( + + ); + const channelTypeContainer = getByTestId('channel-type'); + const channelLabel = channelTypeOptions.find( + (option) => option.value === mockData[0].channel_type + )?.label; + user.click( + within(channelTypeContainer).getByRole('button', { name: 'Open' }) + ); + expect( + await findByRole('option', { + name: channelLabel, + }) + ).toBeInTheDocument(); + + await userEvent.click(await findByRole('option', { name: channelLabel })); + expect(within(channelTypeContainer).getByRole('combobox')).toHaveAttribute( + 'value', + channelLabel + ); + }); + it('should render the label component with happy path and able to select an option', async () => { + const { findByRole, getByRole, getByTestId } = renderWithTheme( + + ); + // selecting the type as the label field is disabled with type is null + const channelTypeContainer = getByTestId('channel-type'); + await user.click( + within(channelTypeContainer).getByRole('button', { name: 'Open' }) + ); + await user.click( + await findByRole('option', { + name: 'Email', + }) + ); + expect(within(channelTypeContainer).getByRole('combobox')).toHaveAttribute( + 'value', + 'Email' + ); + + const channelLabelContainer = getByTestId('channel-label'); + await user.click( + within(channelLabelContainer).getByRole('button', { name: 'Open' }) + ); + expect( + getByRole('option', { + name: mockData[0].label, + }) + ).toBeInTheDocument(); + + await userEvent.click( + await findByRole('option', { + name: mockData[0].label, + }) + ); + expect(within(channelLabelContainer).getByRole('combobox')).toHaveAttribute( + 'value', + mockData[0].label + ); + }); + + it('should render the error messages from the client side validation', async () => { + const { getAllByText, getByRole } = renderWithTheme( + + ); + await user.click(getByRole('button', { name: 'Add channel' })); + expect(getAllByText('This field is required.').length).toBe(2); + getAllByText('This field is required.').forEach((element) => { + expect(element).toBeVisible(); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx new file mode 100644 index 00000000000..12238c3c375 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannel.tsx @@ -0,0 +1,181 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { Autocomplete, Box, Typography } from '@linode/ui'; +import React from 'react'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; + +import { channelTypeOptions } from '../../constants'; +import { getAlertBoxStyles } from '../../Utils/utils'; +import { notificationChannelSchema } from '../schemas'; + +import type { NotificationChannelForm } from '../types'; +import type { ChannelType, NotificationChannel } from '@linode/api-v4'; +import type { ObjectSchema } from 'yup'; + +interface AddNotificationChannelProps { + /** + * Boolean for the Notification channels api error response + */ + isNotificationChannelsError: boolean; + /** + * Boolean for the Notification channels api loading response + */ + isNotificationChannelsLoading: boolean; + /** + * Method to exit the Drawer on cancel + * @returns void + */ + onCancel: () => void; + /** + * Method to add the notification id to the form context + * @param notificationId id of the Notification that is being submitted + * @returns void + */ + onSubmitAddNotification: (notificationId: number) => void; + /** + * Notification template data fetched from the api + */ + templateData: NotificationChannel[]; +} + +export const AddNotificationChannel = (props: AddNotificationChannelProps) => { + const { + isNotificationChannelsError, + isNotificationChannelsLoading, + onCancel, + onSubmitAddNotification, + templateData, + } = props; + + const formMethods = useForm({ + defaultValues: { + channel_type: null, + label: null, + }, + mode: 'onBlur', + resolver: yupResolver( + notificationChannelSchema as ObjectSchema + ), + }); + + const { control, handleSubmit, setValue } = formMethods; + const onSubmit = handleSubmit(() => { + onSubmitAddNotification(selectedTemplate?.id ?? 0); + }); + + const channelTypeWatcher = useWatch({ control, name: 'channel_type' }); + const channelLabelWatcher = useWatch({ control, name: 'label' }); + const selectedChannelTypeTemplate = + channelTypeWatcher && templateData + ? templateData.filter( + (template) => template.channel_type === channelTypeWatcher + ) + : null; + + const selectedTemplate = selectedChannelTypeTemplate?.find( + (template) => template.label === channelLabelWatcher + ); + + return ( + +
    + ({ + ...getAlertBoxStyles(theme), + borderRadius: 1, + overflow: 'auto', + p: 2, + })} + > + ({ + color: theme.tokens.content.Text, + })} + gutterBottom + variant="h3" + > + Channel Settings + + ( + { + field.onChange( + reason === 'selectOption' ? newValue.value : null + ); + if (reason !== 'selectOption') { + setValue('label', null); + } + }} + value={ + channelTypeOptions.find( + (option) => option.value === field.value + ) ?? null + } + data-testid="channel-type" + label="Type" + onBlur={field.onBlur} + options={channelTypeOptions} + placeholder="Select a Type" + /> + )} + control={control} + name="channel_type" + /> + + ( + { + field.onChange( + reason === 'selectOption' ? selected.label : null + ); + }} + value={ + selectedChannelTypeTemplate?.find( + (option) => option.label === field.value + ) ?? null + } + data-testid="channel-label" + disabled={!selectedChannelTypeTemplate} + errorText={fieldState.error?.message} + key={channelTypeWatcher} + label="Channel" + onBlur={field.onBlur} + options={selectedChannelTypeTemplate ?? []} + placeholder="Select a Channel" + /> + )} + control={control} + name="label" + /> + + + + +
    + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts index 8b9301c3ebf..77e667d3237 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -1,6 +1,8 @@ import { createAlertDefinitionSchema } from '@linode/validation'; import { object, string } from 'yup'; +const fieldErrorMessage = 'This field is required.'; + const engineOptionValidation = string().when('service_type', { is: 'dbaas', otherwise: (schema) => schema.notRequired().nullable(), @@ -14,3 +16,8 @@ export const CreateAlertDefinitionFormSchema = createAlertDefinitionSchema.conca serviceType: string().required('Service is required.'), }) ); + +export const notificationChannelSchema = object({ + channel_type: string().required(fieldErrorMessage), + label: string().required(fieldErrorMessage), +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 90671fce719..a25582af56d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -1,6 +1,7 @@ import type { AlertServiceType, AlertSeverityType, + ChannelType, CreateAlertDefinitionPayload, DimensionFilter, DimensionFilterOperatorType, @@ -52,3 +53,8 @@ export interface TriggerConditionForm evaluation_period_seconds: null | number; polling_interval_seconds: null | number; } + +export interface NotificationChannelForm { + channel_type: ChannelType | null; + label: null | string; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index d7bc658875c..8d8ff5bd25d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,6 +1,7 @@ import type { AlertSeverityType, AlertStatusType, + ChannelType, DimensionFilterOperatorType, MetricAggregationType, MetricOperatorType, @@ -128,6 +129,21 @@ export const alertStatusToIconStatusMap: Record = { disabled: 'inactive', enabled: 'active', }; + +export const channelTypes: Record = { + email: 'Email', + pagerduty: 'Pagerduty', + slack: 'Slack', + webhook: 'Webhook', +}; + +export const channelTypeOptions: Item[] = Object.entries( + channelTypes +).map(([key, label]) => ({ + label, + value: key as ChannelType, +})); + export const metricOperatorTypeMap: Record = { eq: '=', gt: '>', From 26818324380b75f0b7f8ac87f8162569bdb64d26 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:02:40 -0500 Subject: [PATCH 34/42] fix: Notice alignment and Linode details button spacing (#11535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description ๐Ÿ“ Fix some Notice alignment regressions from https://github.com/linode/manager/pull/11480. (There might be more minor notice alignment issues but these were the ones I noticed) Also noticed the in-line action button padding looks off in the Linode entity header so adjusted that to match our other entity detail headers ## How to test ๐Ÿงช ### Verification steps (How to verify changes) - [ ] Try to power off a Linode to open the dialog, the alignment should be fixed - [ ] Go to Linode details page and notice in-line header buttons x padding matches LKE entity header - [ ] Go to StackScript Create, the tips box alignment should be fixed --------- Co-authored-by: Banks Nussman --- .../ConfirmationDialog/ConfirmationDialog.tsx | 2 +- .../NodePoolsDisplay/AutoscalePoolDialog.tsx | 15 ++-- .../Linodes/LinodeEntityDetailHeader.tsx | 1 + .../Linodes/PowerActionsDialogOrDrawer.tsx | 68 ++++++++----------- .../ui/src/components/Notice/Notice.styles.ts | 7 +- 5 files changed, 39 insertions(+), 54 deletions(-) diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 880b54598b2..2c49ff413eb 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -34,7 +34,7 @@ export const ConfirmationDialog = React.forwardRef< direction="row" justifyContent="flex-end" spacing={2} - sx={{ mt: 4 }} + sx={{ mt: 2 }} > {actions && typeof actions === 'function' ? actions(dialogProps) diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx index fb0bdbabf88..f2cf1a063af 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx @@ -33,9 +33,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ disabled: { opacity: 0.5, }, - errorText: { - color: theme.color.red, - }, input: { '& input': { width: 70, @@ -219,16 +216,16 @@ export const AutoscalePoolDialog = (props: Props) => { />
    - {errors.min ? ( - + {errors.min && ( + theme.palette.error.dark}> {errors.min} - ) : null} - {errors.max ? ( - + )} + {errors.max && ( + theme.palette.error.dark}> {errors.max} - ) : null} + )}
    diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index d21c7b67087..2c5c4059209 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -134,6 +134,7 @@ export const LinodeEntityDetailHeader = ( fontSize: '0.875rem', height: theme.spacing(5), minWidth: 'auto', + padding: '2px 10px', }; const sxBoxFlex = { diff --git a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx index d2cddd82d7a..4376f0bdf30 100644 --- a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx +++ b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, FormHelperText, Notice, Typography } from '@linode/ui'; +import { Autocomplete, Notice, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -139,18 +139,17 @@ export const PowerActionsDialog = (props: Props) => { secondaryButtonProps={{ label: 'Cancel', onClick: props.onClose }} /> } - sx={{ - '& .dialog-content': { - paddingBottom: 0, - paddingTop: 0, - }, - }} error={error?.[0].reason} onClose={handleOnClose} open={isOpen} title={`${action} Linode ${linodeLabel ?? ''}?`} > - {isPowerOnAction ? ( + {isRebootAction && ( + + Are you sure you want to reboot this Linode? + + )} + {isPowerOnAction && ( {  for more information. - ) : null} + )} {showConfigSelect && ( - <> - option.value === selectedConfigID - )} - autoHighlight - disablePortal={false} - errorText={configsError?.[0].reason} - label="Config" - loading={configsLoading} - onChange={(_, option) => setSelectConfigID(option?.value ?? null)} - options={configOptions} - /> - - If no value is selected, the last booted config will be used. - - + option.value === selectedConfigID + )} + autoHighlight + disablePortal={false} + errorText={configsError?.[0].reason} + label="Config" + loading={configsLoading} + onChange={(_, option) => setSelectConfigID(option?.value ?? null)} + options={configOptions} + helperText='If no value is selected, the last booted config will be used.' + /> )} {props.action === 'Power Off' && ( - - - Note: - Powered down Linodes will still accrue charges. -
    - See the  + + + Note: Powered down Linodes will still accrue + charges. See the  Billing and Payments documentation  for more information. - -
    + +
    )} ); diff --git a/packages/ui/src/components/Notice/Notice.styles.ts b/packages/ui/src/components/Notice/Notice.styles.ts index 088f1256728..cfb61d71c46 100644 --- a/packages/ui/src/components/Notice/Notice.styles.ts +++ b/packages/ui/src/components/Notice/Notice.styles.ts @@ -6,14 +6,16 @@ export const useStyles = makeStyles()((theme) => ({ }, icon: { color: theme.tokens.color.Neutrals.White, - left: -25, // This value must be static regardless of theme selection position: 'absolute', + left: -25, + transform: "translateY(-50%)", + top: '50%', }, important: { backgroundColor: theme.palette.background.paper, borderLeftWidth: 32, fontFamily: theme.font.normal, - padding: theme.spacing(1), + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, }, info: { borderLeft: `5px solid ${theme.palette.info.dark}`, @@ -29,7 +31,6 @@ export const useStyles = makeStyles()((theme) => ({ }, alignItems: 'center', borderRadius: 1, - display: 'flex', fontSize: '1rem', maxWidth: '100%', padding: `${theme.spacing(0.5)} ${theme.spacing(2)}`, From 90e7224927790afbb4ed134e6ea5c5b642408716 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:21:54 -0500 Subject: [PATCH 35/42] test: [M3-9131] - Increase Linode clone timeout to 5 minutes (#11529) * Increase Linode clone timeout to 5 minutes * Added changeset: Increase timeouts when performing Linode clone operations --- .../.changeset/pr-11529-tests-1737049089021.md | 5 +++++ .../cypress/e2e/core/linodes/clone-linode.spec.ts | 13 ++++++++----- .../cypress/e2e/core/linodes/linode-config.spec.ts | 4 +++- .../manager/cypress/support/constants/linodes.ts | 7 +++++++ 4 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-11529-tests-1737049089021.md diff --git a/packages/manager/.changeset/pr-11529-tests-1737049089021.md b/packages/manager/.changeset/pr-11529-tests-1737049089021.md new file mode 100644 index 00000000000..ccc8d489fc5 --- /dev/null +++ b/packages/manager/.changeset/pr-11529-tests-1737049089021.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Increase timeouts when performing Linode clone operations ([#11529](https://github.com/linode/manager/pull/11529)) diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 55a1ce53a76..0f698c50e20 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -18,6 +18,10 @@ import { randomLabel } from 'support/util/random'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; +import { + LINODE_CLONE_TIMEOUT, + LINODE_CREATE_TIMEOUT, +} from 'support/constants/linodes'; import type { Linode } from '@linode/api-v4'; /** @@ -33,9 +37,6 @@ const getLinodeCloneUrl = (linode: Linode): string => { return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; -/* Timeout after 4 minutes while waiting for clone. */ -const CLONE_TIMEOUT = 240_000; - authenticate(); describe('clone linode', () => { before(() => { @@ -69,7 +70,9 @@ describe('clone linode', () => { cy.visitWithLogin(`/linodes/${linode.id}`); // Wait for Linode to boot, then initiate clone flow. - cy.findByText('OFFLINE').should('be.visible'); + cy.findByText('OFFLINE', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); ui.actionMenu .findByTitle(`Action menu for Linode ${linode.label}`) @@ -108,7 +111,7 @@ describe('clone linode', () => { ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); ui.toast.assertMessage( `Linode ${linode.label} has been cloned to ${newLinodeLabel}.`, - { timeout: CLONE_TIMEOUT } + { timeout: LINODE_CLONE_TIMEOUT } ); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 68ec28b9701..903c5932224 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -4,6 +4,7 @@ import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; +import { LINODE_CLONE_TIMEOUT } from 'support/constants/linodes'; import { chooseRegion, getRegionById } from 'support/util/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { @@ -225,7 +226,8 @@ describe('Linode Config management', () => { .its('response.statusCode') .should('eq', 200); ui.toast.assertMessage( - `Configuration ${config.label} successfully updated` + `Configuration ${config.label} successfully updated`, + { timeout: LINODE_CLONE_TIMEOUT } ); // Confirm that updated IPAM is automatically listed in config table. diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts index 35d6762f810..c09601fe5de 100644 --- a/packages/manager/cypress/support/constants/linodes.ts +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -8,3 +8,10 @@ * Equals 5 minutes. */ export const LINODE_CREATE_TIMEOUT = 300_000; + +/** + * Length of time to wait for a Linode to be cloned. + * + * Equals 5 minutes. + */ +export const LINODE_CLONE_TIMEOUT = 300_000; From b72c97389e3a150a007ff3e82464e439d3a975ca Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:40:54 -0500 Subject: [PATCH 36/42] change: Improve search syntax for `+neq` (#11521) * improve not equal syntax * add changeset --------- Co-authored-by: Banks Nussman --- .../pr-11521-changed-1736978874581.md | 5 ++++ packages/search/README.md | 24 +++++++++---------- packages/search/src/search.peggy | 7 +++--- packages/search/src/search.test.ts | 11 +++++++++ 4 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 packages/manager/.changeset/pr-11521-changed-1736978874581.md diff --git a/packages/manager/.changeset/pr-11521-changed-1736978874581.md b/packages/manager/.changeset/pr-11521-changed-1736978874581.md new file mode 100644 index 00000000000..2acbd957cfa --- /dev/null +++ b/packages/manager/.changeset/pr-11521-changed-1736978874581.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Search v2 not equal syntax ([#11521](https://github.com/linode/manager/pull/11521)) diff --git a/packages/search/README.md b/packages/search/README.md index 56980bfa80f..bed7cb4d000 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -1,6 +1,6 @@ # Search -Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). +Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Connected Cloud Manager. @@ -30,14 +30,14 @@ label: my-volume and size >= 20 ## Supported Operations -| Operation | Aliases | Example | Description | -|-----------|----------------|--------------------------------|-----------------------------------------------------------------| -| `and` | `&`, `&&` | `label: prod and size > 20` | Performs a boolean *and* on two expressions | -| `or` | `|`, `||` | `label: prod or size > 20` | Performs a boolean *or* on two expressions | -| `>` | None | `size > 20` | Greater than | -| `<` | None | `size < 20` | Less than | -| `>=` | None | `size >= 20` | Great than or equal to | -| `<=` | None | `size <= 20` | Less than or equal to | -| `!` | `-` | `!label = my-linode-1` | Not equal to (does not work as a *not* for boolean expressions) | -| `=` | None | `label = my-linode-1` | Equal to | -| `:` | `~` | `label: my-linode` | Contains | +| Operation | Aliases | Example | Description | +|-----------|----------------|--------------------------------|--------------------------------------------------------------------------------------------| +| `and` | `&`, `&&`, ` ` | `label: prod and size > 20` | Performs a boolean *and* on two expressions (whitespace is interpreted as "and") | +| `or` | `\|`, `\|\|` | `label: prod or size > 20` | Performs a boolean *or* on two expressions | +| `>` | None | `size > 20` | Greater than | +| `<` | None | `size < 20` | Less than | +| `>=` | None | `size >= 20` | Great than or equal to | +| `<=` | None | `size <= 20` | Less than or equal to | +| `!=` | None | `size != 1024` | Not equal to (does not work as a *not* for boolean expressions. Only works as "not equal") | +| `=` | None | `label = my-linode-1` | Equal to | +| `:` | `~` | `label: my-linode` | Contains | diff --git a/packages/search/src/search.peggy b/packages/search/src/search.peggy index 2ca9b0fd5b0..1783ec4c103 100644 --- a/packages/search/src/search.peggy +++ b/packages/search/src/search.peggy @@ -49,7 +49,7 @@ TagQuery = "tag" ws* Contains ws* value:String { return { "tags": { "+contains": value } }; } NotEqualQuery - = Not key:FilterableField ws* Equal ws* value:String { return { [key]: { "+neq": value } }; } + = key:FilterableField ws* NotEqual ws* value:SearchValue { return { [key]: { "+neq": value } }; } LessThanQuery = key:FilterableField ws* Less ws* value:Number { return { [key]: { "+lt": value } }; } @@ -74,9 +74,8 @@ And / ws* '&' ws* / ws -Not - = '!' - / '-' +NotEqual + = '!=' Less = '<' diff --git a/packages/search/src/search.test.ts b/packages/search/src/search.test.ts index 93e691c5081..3e2abb209f9 100644 --- a/packages/search/src/search.test.ts +++ b/packages/search/src/search.test.ts @@ -35,6 +35,17 @@ describe("getAPIFilterFromQuery", () => { }); }); + it("handles +neq", () => { + const query = "status != active"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + status: { '+neq': "active" }, + }, + error: null, + }); + }); + it("handles +lt", () => { const query = "size < 20"; From 163c8991ed22bac3f287ae88e54bd7568dff81e9 Mon Sep 17 00:00:00 2001 From: hasyed-akamai Date: Wed, 22 Jan 2025 15:46:12 +0530 Subject: [PATCH 37/42] refactor: [M3-6916] Replace EnhancedSelect with Autocomplete in: help (#11470) * refactor: [M3-6916] Replace EnhancedSelect with Autocomplete in: help * Added changeset: Replace EnhancedSelect with Autocomplete component in the Help feature * Fix search redirect and Autocomplete options width * Change hover colors * Remove Hover regression * Added Stlying as per the CDS Mockup * Cleanup * Replace colors with Design Tokens * Eliminate the use of classNames for `sx` components. * Cleanup * Add a note about necessary env vars for working search * Remove the use of `searchtext` prop --------- Co-authored-by: mjac0bs --- .../pr-11470-tech-stories-1735907007536.md | 5 + .../features/Help/Panels/AlgoliaSearchBar.tsx | 178 ++++++++++-------- .../src/features/Help/Panels/SearchItem.tsx | 75 +++++--- .../manager/src/features/Help/SearchHOC.tsx | 17 +- packages/ui/src/foundations/breakpoints.ts | 4 +- packages/ui/src/foundations/themes/dark.ts | 19 ++ packages/ui/src/foundations/themes/index.ts | 9 +- packages/ui/src/foundations/themes/light.ts | 16 +- 8 files changed, 203 insertions(+), 120 deletions(-) create mode 100644 packages/manager/.changeset/pr-11470-tech-stories-1735907007536.md diff --git a/packages/manager/.changeset/pr-11470-tech-stories-1735907007536.md b/packages/manager/.changeset/pr-11470-tech-stories-1735907007536.md new file mode 100644 index 00000000000..d34523cf58d --- /dev/null +++ b/packages/manager/.changeset/pr-11470-tech-stories-1735907007536.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace EnhancedSelect with Autocomplete component in the Help feature ([#11470](https://github.com/linode/manager/pull/11470)) diff --git a/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx b/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx index 9ea11aa21f4..b49c1c042fc 100644 --- a/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx +++ b/packages/manager/src/features/Help/Panels/AlgoliaSearchBar.tsx @@ -1,66 +1,29 @@ -import { Notice } from '@linode/ui'; +import { Autocomplete, InputAdornment, Notice } from '@linode/ui'; import Search from '@mui/icons-material/Search'; import { pathOr } from 'ramda'; import * as React from 'react'; import { withRouter } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; -import { makeStyles } from 'tss-react/mui'; - -import EnhancedSelect from 'src/components/EnhancedSelect'; -import { selectStyles } from 'src/features/TopMenu/SearchBar/SearchBar'; import withSearch from '../SearchHOC'; import { SearchItem } from './SearchItem'; import type { AlgoliaState as AlgoliaProps } from '../SearchHOC'; -import type { Theme } from '@mui/material/styles'; +import type { ConvertedItems } from '../SearchHOC'; import type { RouteComponentProps } from 'react-router-dom'; -import type { Item } from 'src/components/EnhancedSelect'; - -const useStyles = makeStyles()((theme: Theme) => ({ - enhancedSelectWrapper: { - '& .input': { - '& > div': { - marginRight: 0, - }, - '& p': { - color: theme.color.grey1, - paddingLeft: theme.spacing(3), - }, - maxWidth: '100%', - }, - '& .react-select__value-container': { - paddingLeft: theme.spacing(4), - }, - margin: '0 auto', - maxHeight: 500, - [theme.breakpoints.up('md')]: { - width: 500, - }, - width: 300, - }, - notice: { - '& p': { - color: theme.color.white, - fontFamily: 'LatoWeb', - }, - }, - root: { - position: 'relative', - }, - searchIcon: { - color: theme.color.grey1, - left: 5, - position: 'absolute', - top: 4, - zIndex: 3, - }, -})); +interface SelectedItem { + data: { source: string }; + label: string; + value: string; +} interface AlgoliaSearchBarProps extends AlgoliaProps, RouteComponentProps<{}> {} +/** + * For Algolia search to work locally, ensure you have valid values set for + * REACT_APP_ALGOLIA_APPLICATION_ID and REACT_APP_ALGOLIA_SEARCH_KEY in your .env file. + */ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { - const { classes } = useStyles(); const [inputValue, setInputValue] = React.useState(''); const { history, @@ -98,47 +61,112 @@ const AlgoliaSearchBar = (props: AlgoliaSearchBarProps) => { : '/support/search/'; }; - const handleSelect = (selected: Item) => { + const handleSelect = (selected: ConvertedItems | SelectedItem | null) => { if (!selected || !inputValue) { return; } - if (selected.value === 'search') { + const href = pathOr('', ['data', 'href'], selected); + if (href) { + // If an href exists for the selected option, redirect directly to that link. + window.open(href, '_blank', 'noopener'); + } else { + // If no href, we redirect to the search landing page. const link = getLinkTarget(inputValue); history.push(link); - } else { - const href = pathOr('', ['data', 'href'], selected); - window.open(href, '_blank', 'noopener'); } }; - return ( {searchError && ( - + ({ + '& p': { + color: theme.color.white, + fontFamily: 'LatoWeb', + }, + })} + spacingTop={8} + variant="error" + > {searchError} )} -
    - - null, Option: SearchItem } as any - } - className={classes.enhancedSelectWrapper} - disabled={!searchEnabled} - hideLabel - inputValue={inputValue} - isClearable={true} - isMulti={false} - label="Search for answers" - onChange={handleSelect} - onInputChange={onInputValueChange} - options={options} - placeholder="Search for answers..." - styles={selectStyles} - /> -
    + { + return ( + + ); + }} + slotProps={{ + paper: { + sx: (theme) => ({ + '& .MuiAutocomplete-listbox': { + '&::-webkit-scrollbar': { + display: 'none', + }, + border: 'none !important', + msOverflowStyle: 'none', + scrollbarWidth: 'none', + }, + '& .MuiAutocomplete-option': { + ':hover': { + backgroundColor: + theme.name == 'light' + ? `${theme.tokens.color.Brand[10]} !important` + : `${theme.tokens.color.Neutrals[80]} !important`, + color: theme.color.black, + transition: 'background-color 0.2s', + }, + }, + boxShadow: '0px 2px 8px 0px rgba(58, 59, 63, 0.18)', + marginTop: 0.5, + }), + }, + }} + sx={(theme) => ({ + maxHeight: 500, + [theme.breakpoints.up('md')]: { + width: 500, + }, + width: 300, + })} + textFieldProps={{ + InputProps: { + startAdornment: ( + + ({ + color: `${theme.tokens.search.Default.SearchIcon} !important`, + })} + data-qa-search-icon + /> + + ), + sx: (theme) => ({ + '&.Mui-focused': { + borderColor: `${theme.tokens.color.Brand[70]} !important`, + boxShadow: 'none', + }, + ':hover': { + borderColor: theme.tokens.search.Hover.Border, + }, + }), + }, + hideLabel: true, + }} + disabled={!searchEnabled} + inputValue={inputValue} + label="Search for answers" + onChange={(_, selected) => handleSelect(selected)} + onInputChange={(_, value) => onInputValueChange(value)} + options={options} + placeholder="Search" + />
    ); }; diff --git a/packages/manager/src/features/Help/Panels/SearchItem.tsx b/packages/manager/src/features/Help/Panels/SearchItem.tsx index 96aef5779ac..80bee89b327 100644 --- a/packages/manager/src/features/Help/Panels/SearchItem.tsx +++ b/packages/manager/src/features/Help/Panels/SearchItem.tsx @@ -1,58 +1,68 @@ -import { Typography } from '@linode/ui'; +import { ListItem, Typography } from '@linode/ui'; import * as React from 'react'; -import { useStyles } from 'tss-react/mui'; +import { makeStyles } from 'tss-react/mui'; import Arrow from 'src/assets/icons/diagonalArrow.svg'; -import { Option } from 'src/components/EnhancedSelect/components/Option'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; -import type { OptionProps } from 'react-select'; +import type { Theme } from '@mui/material/styles'; -interface Props extends OptionProps { +const useStyles = makeStyles()((theme: Theme) => ({ + arrow: { + color: theme.palette.primary.main, + height: 12, + width: 12, + }, + root: { + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +})); + +interface Props { data: { - data: any; + data: { + source: string; + }; label: string; }; - searchText: string; } export const SearchItem = (props: Props) => { + const { data } = props; const getLabel = () => { if (isFinal) { - return props.label ? `Search for "${props.label}"` : 'Search'; + return data.label ? `Search for "${data.label}"` : 'Search'; } else { - return props.label; + return data.label; } }; - const { cx } = useStyles(); + const { classes } = useStyles(); - const { - data, - isFocused, - selectProps: { classes }, - } = props; const source = data.data ? data.data.source : ''; const isFinal = source === 'finalLink'; return ( -