diff --git a/package.json b/package.json index 6f2d0b63ed4..46ba965031f 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "resolutions": { "node-fetch": "^2.6.7", "yaml": "^2.3.0", - "semver": "^7.5.2" + "semver": "^7.5.2", + "cookie": "^0.7.0" }, "workspaces": { "packages": [ diff --git a/packages/api-v4/.changeset/pr-11445-changed-1734706802321.md b/packages/api-v4/.changeset/pr-11445-changed-1734706802321.md new file mode 100644 index 00000000000..2e2964019a7 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11445-changed-1734706802321.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Changed MetricCritera, DimensionFilter and Alert Interfaces ([#11445](https://github.com/linode/manager/pull/11445)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 60b8a804936..2e4e6c4658a 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -2,7 +2,11 @@ export type AlertSeverityType = 0 | 1 | 2 | 3; export type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count'; export type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; export type AlertServiceType = 'linode' | 'dbaas'; -type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; +export type DimensionFilterOperatorType = + | 'eq' + | 'neq' + | 'startswith' + | 'endswith'; export type AlertDefinitionType = 'system' | 'user'; export type AlertStatusType = 'enabled' | 'disabled'; export type CriteriaConditionType = 'ALL'; @@ -164,20 +168,24 @@ export interface MetricCriteria { aggregation_type: MetricAggregationType; operator: MetricOperatorType; threshold: number; - dimension_filters: DimensionFilter[]; + dimension_filters?: DimensionFilter[]; } -export interface AlertDefinitionMetricCriteria extends MetricCriteria { +export interface AlertDefinitionMetricCriteria + extends Omit { unit: string; label: string; + dimension_filters?: AlertDefinitionDimensionFilter[]; } export interface DimensionFilter { - label: string; dimension_label: string; operator: DimensionFilterOperatorType; value: string; } +export interface AlertDefinitionDimensionFilter extends DimensionFilter { + label: string; +} export interface TriggerCondition { polling_interval_seconds: number; evaluation_period_seconds: number; diff --git a/packages/manager/.changeset/pr-10820-changed-1725610034084.md b/packages/manager/.changeset/pr-10820-changed-1725610034084.md new file mode 100644 index 00000000000..9c33175af12 --- /dev/null +++ b/packages/manager/.changeset/pr-10820-changed-1725610034084.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Changed +--- + +Volume drawers and action menu ([#10820](https://github.com/linode/manager/pull/10820)) diff --git a/packages/manager/.changeset/pr-11360-tech-stories-1733774598326.md b/packages/manager/.changeset/pr-11360-tech-stories-1733774598326.md new file mode 100644 index 00000000000..2d63e9c5432 --- /dev/null +++ b/packages/manager/.changeset/pr-11360-tech-stories-1733774598326.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add `IMAGE_REGISTRY` Docker build argument ([#11360](https://github.com/linode/manager/pull/11360)) diff --git a/packages/manager/.changeset/pr-11408-tests-1734042066746.md b/packages/manager/.changeset/pr-11408-tests-1734042066746.md new file mode 100644 index 00000000000..869afe3b6f0 --- /dev/null +++ b/packages/manager/.changeset/pr-11408-tests-1734042066746.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add cypress component tests for Autocomplete ([#11408](https://github.com/linode/manager/pull/11408)) diff --git a/packages/manager/.changeset/pr-11428-tech-stories-1734382681885.md b/packages/manager/.changeset/pr-11428-tech-stories-1734382681885.md new file mode 100644 index 00000000000..319aef8f779 --- /dev/null +++ b/packages/manager/.changeset/pr-11428-tech-stories-1734382681885.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add MSW crud domains ([#11428](https://github.com/linode/manager/pull/11428)) diff --git a/packages/manager/.changeset/pr-11434-tech-stories-1734487085461.md b/packages/manager/.changeset/pr-11434-tech-stories-1734487085461.md new file mode 100644 index 00000000000..c4a84e4cef1 --- /dev/null +++ b/packages/manager/.changeset/pr-11434-tech-stories-1734487085461.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Patch `cookie` version as resolution for dependabot ([#11434](https://github.com/linode/manager/pull/11434)) diff --git a/packages/manager/.changeset/pr-11440-tests-1734593684793.md b/packages/manager/.changeset/pr-11440-tests-1734593684793.md new file mode 100644 index 00000000000..6760cfd5001 --- /dev/null +++ b/packages/manager/.changeset/pr-11440-tests-1734593684793.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Cypress test to validate CAA records are editable ([#11440](https://github.com/linode/manager/pull/11440)) diff --git a/packages/manager/.changeset/pr-11444-tests-1734632019649.md b/packages/manager/.changeset/pr-11444-tests-1734632019649.md new file mode 100644 index 00000000000..ffc53ad1798 --- /dev/null +++ b/packages/manager/.changeset/pr-11444-tests-1734632019649.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add test for LKE cluster rename flow ([#11444](https://github.com/linode/manager/pull/11444)) diff --git a/packages/manager/.changeset/pr-11445-added-1734705630196.md b/packages/manager/.changeset/pr-11445-added-1734705630196.md new file mode 100644 index 00000000000..46161fa6057 --- /dev/null +++ b/packages/manager/.changeset/pr-11445-added-1734705630196.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +DimensionFilter, DimensionFilterField, TriggerCondition component along with Unit Tests ([#11445](https://github.com/linode/manager/pull/11445)) diff --git a/packages/manager/.changeset/pr-11453-fixed-1734765509208.md b/packages/manager/.changeset/pr-11453-fixed-1734765509208.md new file mode 100644 index 00000000000..07f505ebe8d --- /dev/null +++ b/packages/manager/.changeset/pr-11453-fixed-1734765509208.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +`Create Token` button becomes disabled when all permissions are selected individually (without using 'select all') and child-account is hidden ([#11453](https://github.com/linode/manager/pull/11453)) diff --git a/packages/manager/.changeset/pr-11456-tech-stories-1734933761673.md b/packages/manager/.changeset/pr-11456-tech-stories-1734933761673.md new file mode 100644 index 00000000000..d8988aa46cb --- /dev/null +++ b/packages/manager/.changeset/pr-11456-tech-stories-1734933761673.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace Select with Autocomplete component in Object Storage ([#11456](https://github.com/linode/manager/pull/11456)) diff --git a/packages/manager/.changeset/pr-11472-fixed-1736174726210.md b/packages/manager/.changeset/pr-11472-fixed-1736174726210.md new file mode 100644 index 00000000000..2b5ecf34bd9 --- /dev/null +++ b/packages/manager/.changeset/pr-11472-fixed-1736174726210.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Object Storage `endpoint_type` sorting ([#11472](https://github.com/linode/manager/pull/11472)) diff --git a/packages/manager/.changeset/pr-11476-fixed-1736263048231.md b/packages/manager/.changeset/pr-11476-fixed-1736263048231.md new file mode 100644 index 00000000000..738f0264865 --- /dev/null +++ b/packages/manager/.changeset/pr-11476-fixed-1736263048231.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Visibility of sensitive data in Managed and Longview with Mask Sensitive Data setting enabled ([#11476](https://github.com/linode/manager/pull/11476)) diff --git a/packages/manager/.changeset/pr-11478-tests-1736180178602.md b/packages/manager/.changeset/pr-11478-tests-1736180178602.md new file mode 100644 index 00000000000..0de29c64102 --- /dev/null +++ b/packages/manager/.changeset/pr-11478-tests-1736180178602.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add coverage for Kube version upgrades in landing page ([#11478](https://github.com/linode/manager/pull/11478)) diff --git a/packages/manager/.changeset/pr-11486-tests-1736284627318.md b/packages/manager/.changeset/pr-11486-tests-1736284627318.md new file mode 100644 index 00000000000..35b14523b44 --- /dev/null +++ b/packages/manager/.changeset/pr-11486-tests-1736284627318.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix Cypress test failures stemming from Debian 10 Image deprecation ([#11486](https://github.com/linode/manager/pull/11486)) diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index 37e1785a48a..d7ba0a3eb4d 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -1,8 +1,12 @@ +# Registry to use when pulling images. +# Defaults to Docker Hub, but can be overriden to point to another registry if needed. +ARG IMAGE_REGISTRY=docker.io + # Node.js base image for Cloud Manager CI tasks. # # Extends from the Node.js base image that corresponds with our latest supported # version of Node, and includes other tools that we rely on like pnpm and bun. -FROM node:20.17-bullseye-slim as nodejs-cloud-manager +FROM ${IMAGE_REGISTRY}/node:20.17-bullseye-slim as nodejs-cloud-manager RUN npm install -g pnpm bun # `manager` @@ -28,7 +32,7 @@ CMD yarn start:manager:ci # # Builds an image containing Cypress and miscellaneous system utilities required # by the tests. -FROM cypress/included:13.11.0 as e2e-build +FROM ${IMAGE_REGISTRY}/cypress/included:13.11.0 as e2e-build RUN npm install -g pnpm bun USER node WORKDIR /home/node/app diff --git a/packages/manager/cypress/component/components/autocomplete.spec.tsx b/packages/manager/cypress/component/components/autocomplete.spec.tsx new file mode 100644 index 00000000000..b9b8e27d20e --- /dev/null +++ b/packages/manager/cypress/component/components/autocomplete.spec.tsx @@ -0,0 +1,688 @@ +import { Autocomplete } from '@linode/ui'; +import * as React from 'react'; +import { ui } from 'support/ui'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests, visualTests } from 'support/util/components'; +import { createSpy } from 'support/util/components'; + +type Option = { + label: string; + value: string; +}; + +componentTests('Autocomplete', (mount) => { + const options: Option[] = Array.from({ length: 3 }, (_, index) => { + const num = index + 1; + return { + label: `my-option-${num}`, + value: `my-option-${num}`, + }; + }); + + describe('Autocomplete interactions', () => { + describe('Open menu', () => { + /** + * - Confirms dropbdown can be opened by clicking the arrow button + */ + it('can open the drop-down menu by clicking the drop-down arrow', () => { + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .should('be.visible'); + ui.autocompletePopper + .findByTitle(`${options[1].label}`) + .should('be.visible'); + ui.autocompletePopper + .findByTitle(`${options[2].label}`) + .should('be.visible'); + }); + + /** + * - Confirms dropdown can be opened by typing in the textfield + */ + it('can open the drop-down menu by typing into the textfield area', () => { + mount(); + + // Focus text field by clicking "Autocomplete" label. + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper.find().within(() => { + cy.findByText(options[0].label).should('be.visible'); + cy.findByText(options[1].label).should('not.exist'); + cy.findByText(options[2].label).should('not.exist'); + }); + }); + + /** + * - Confirms dropdown menu when there are no options + */ + it('shows the open dropdown menu with no options text', () => { + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.contains('You have no options to choose from').should('be.visible'); + }); + }); + + describe('Closing menu', () => { + // esc, click away, up arrow + /** + * - Confirms autocomplete popper can be closed with the ESC key + */ + it('can close the autocomplete menu with ESC key', () => { + mount( + {}} + options={options} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(options[0].label) + .should('be.visible'); + + cy.get('input').type('{esc}'); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * Confirms autocomplete can be closed by clicking away + */ + it('can close autocomplete popper by clicking away', () => { + mount( + <> + Other Element + {}} + options={options} + /> + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(options[0].label) + .should('be.visible'); + + cy.get('#other-element').click(); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * Confirms autocomplete can be closed by clicking the close button + */ + it('can close autocomplete popper by clicking the close button', () => { + mount( + {}} + options={options} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .should('be.visible'); + + ui.button + .findByAttribute('title', 'Close') + .should('be.visible') + .should('be.enabled') + .click(); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + }); + + describe('Single-select', () => { + /** + * - Confirms user can select an initial option + */ + it('can select an initial option', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={undefined} + /> + ); + + cy.get('input').should( + 'have.attr', + 'placeholder', + 'this is a placeholder' + ); + cy.get('input').should('have.attr', 'value', ''); + cy.findByText('Autocomplete').should('be.visible').click(); + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(options[0].label) + .scrollIntoView() + .should('be.visible') + .click(); + + // Confirm that selection change is reflected by input field value, and that + // the autocomplete popper has been dismissed. + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * - Confirms user can change selection after having selected an option + */ + it('can change the selected option', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={options[0]} + /> + ); + + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + cy.findByText('Autocomplete').should('be.visible').click(); + cy.focused().type(options[1].label); + + ui.autocompletePopper + .findByTitle(options[1].label) + .scrollIntoView() + .should('be.visible') + .click(); + + // Confirm that selection change is reflected by input field value, and that + // the autocomplete popper has been dismissed. + cy.get('input').should('have.attr', 'value', `${options[1].label}`); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /** + * - Confirms selection option can be cleared + */ + it('clears the selected option', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={options[0]} + /> + ); + + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('input').should('have.attr', 'value', ''); + + cy.get('input').should( + 'have.attr', + 'placeholder', + 'this is a placeholder' + ); + }); + + /** + * - Confirms selection cannot be cleared when clearable is disabled + */ + it('cannot clear the selected option when clearable is disabled', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={options[0]} + /> + ); + + cy.get('input').should('have.attr', 'value', `${options[0].label}`); + cy.findByLabelText('Clear').should('not.exist'); + }); + + /** + * - Confirms selection cannot be cleared if nothing was chosen + */ + it('cannot clear selection when nothing is selected', () => { + mount( + {}} + options={options} + placeholder="this is a placeholder" + value={undefined} + /> + ); + + cy.get('input').should('have.attr', 'value', ''); + cy.get('input').should( + 'have.attr', + 'placeholder', + 'this is a placeholder' + ); + + cy.findByLabelText('Clear').should('not.exist'); + }); + + describe('onChange', () => { + /** + * - Confirms onChange is called when option is selected + */ + it('calls `onChange` callback when initially selecting option', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + + /** + * - Confirms `onChange` callback when option is cleared + */ + it('calls `onChange` callback when clearing selection', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + + /** + * - Confirms `onChange` callback when option is changed + */ + it('calls `onChange` callback changing selection', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(`${options[0].label}`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + }); + + /** + * - Confirms onBlur is called when focusing away from selection + */ + it('calls `onBlur` callback when focusing away from selection', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + <> + Other Element + {}} + options={options} + placeholder="this is a placeholder" + value={undefined} + /> + + ); + + cy.findByText('Autocomplete').should('be.visible').click(); + + cy.focused().type(options[0].label); + + ui.autocompletePopper + .findByTitle(options[0].label) + .scrollIntoView() + .should('be.visible') + .click(); + cy.get('#other-element').click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + }); + + describe('Multiselection', () => { + /** + * - Confirms multiple selections can be chosen + * - Confirms clear button clears all options + */ + it('can select multiple options and clears all selected options', () => { + // figure out how to confirm multi selections + // input value doesn't work anymore... (this feels hacky) + const MultiSelect = () => { + const [selectedOptions, setSelectedOptions] = React.useState< + Option[] + >([]); + return ( + <> +
Number of selected options: {selectedOptions.length}
+ setSelectedOptions(value)} + options={options} + value={selectedOptions} + /> + + ); + }; + + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + + ui.autocompletePopper + .findByTitle(options[0].label) + .should('be.visible') + .click(); + cy.findByText('Number of selected options: 1').should('be.visible'); + + ui.autocompletePopper + .findByTitle(options[1].label) + .should('be.visible') + .click(); + cy.findByText('Number of selected options: 2').should('be.visible'); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText('Number of selected options: 0').should('be.visible'); + }); + + /** + * - Confirms 'Select All' and 'Deselect All' work as expected + */ + it('can select all and deselect all', () => { + const MultiSelect = () => { + const [selectedOptions, setSelectedOptions] = React.useState< + Option[] + >([]); + return ( + setSelectedOptions(value)} + options={options} + value={selectedOptions} + /> + ); + }; + + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle('Select All') + .should('be.visible') + .click(); + + cy.findByLabelText('Clear').should('be.visible').should('be.enabled'); + cy.contains('Select All').should('not.exist'); + + // After selecting all elements, 'Deselect All' appears as an option + ui.autocompletePopper + .findByTitle('Deselect All') + .should('be.visible') + .click(); + + cy.findByLabelText('Clear').should('not.exist'); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + }); + + /** + * - Confirms 'Deselect All' appears only when all options are selected (even if 'Select All' wasn't clicked) + * - Confirms 'Select All' appears if not all options have been selected + */ + it('shows Deselect All if all options are selected', () => { + const MultiSelect = () => { + const [selectedOptions, setSelectedOptions] = React.useState< + Option[] + >([]); + return ( + setSelectedOptions(value)} + options={options} + value={selectedOptions} + /> + ); + }; + + mount(); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + // select all options manually, confirm Select all is still visible if not all options selected yet + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + ui.autocompletePopper + .findByTitle('my-option-1') + .should('be.visible') + .click(); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + ui.autocompletePopper + .findByTitle('my-option-2') + .should('be.visible') + .click(); + ui.autocompletePopper.findByTitle('Select All').should('be.visible'); + ui.autocompletePopper + .findByTitle('my-option-3') + .should('be.visible') + .click(); + + // Confirm Deselect All appears, and Select All doesn't exist anymore + ui.autocompletePopper.findByTitle('Deselect All').should('be.visible'); + cy.contains('Select All').should('not.exist'); + }); + + /** + * - Confirms popper remains open in multiselect after selecting an element + */ + it('keeps the popper open even after an element is selected', () => { + mount( + {}} + options={options} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[1].label}`) + .should('be.visible') + .click(); + + ui.autocompletePopper + .findByTitle(`${options[1].label}`) + .should('be.visible'); + cy.get('[data-qa-autocomplete-popper]').should('be.visible'); + }); + }); + + visualTests((mount) => { + describe('Accessibility checks', () => { + describe('Single select', () => { + it('passes aXe check when menu is closed without an item selected', () => { + mount(); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is closed with an item selected', () => { + mount( + + ); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is open with an item selected', () => { + mount( + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + checkComponentA11y(); + }); + }); + + describe('MultiSelect', () => { + it('passes aXe check when menu is closed without an item selected', () => { + mount( + + ); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is closed with an item selected', () => { + mount( + + ); + + checkComponentA11y(); + }); + + it('passes aXe check when menu is open with an item selected', () => { + mount( + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + checkComponentA11y(); + }); + }); + }); + }); + }); +}); diff --git a/packages/manager/cypress/component/poc/beta-chip.spec.tsx b/packages/manager/cypress/component/components/beta-chip.spec.tsx similarity index 99% rename from packages/manager/cypress/component/poc/beta-chip.spec.tsx rename to packages/manager/cypress/component/components/beta-chip.spec.tsx index 962e9ccf2b4..58641a6e63b 100644 --- a/packages/manager/cypress/component/poc/beta-chip.spec.tsx +++ b/packages/manager/cypress/component/components/beta-chip.spec.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { BetaChip } from 'src/components/BetaChip/BetaChip'; -import { componentTests, visualTests } from 'support/util/components'; import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests, visualTests } from 'support/util/components'; + +import { BetaChip } from 'src/components/BetaChip/BetaChip'; componentTests('BetaChip', () => { visualTests((mount) => { diff --git a/packages/manager/cypress/component/poc/region-select.spec.tsx b/packages/manager/cypress/component/components/region-select.spec.tsx similarity index 99% rename from packages/manager/cypress/component/poc/region-select.spec.tsx rename to packages/manager/cypress/component/components/region-select.spec.tsx index e15411dece2..656d5dacaf3 100644 --- a/packages/manager/cypress/component/poc/region-select.spec.tsx +++ b/packages/manager/cypress/component/components/region-select.spec.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { componentTests, visualTests } from 'support/util/components'; -import { checkComponentA11y } from 'support/util/accessibility'; -import { accountAvailabilityFactory, regionFactory } from 'src/factories'; -import { ui } from 'support/ui'; import { mockGetAccountAvailability } from 'support/intercepts/account'; +import { ui } from 'support/ui'; +import { checkComponentA11y } from 'support/util/accessibility'; import { createSpy } from 'support/util/components'; +import { componentTests, visualTests } from 'support/util/components'; + +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { accountAvailabilityFactory, regionFactory } from 'src/factories'; componentTests('RegionSelect', (mount) => { beforeEach(() => { @@ -24,10 +25,10 @@ componentTests('RegionSelect', (mount) => { mount( {}} + regions={[region]} + value={undefined} /> ); @@ -52,10 +53,10 @@ componentTests('RegionSelect', (mount) => { mount( {}} + regions={[region]} + value={undefined} /> ); @@ -81,10 +82,10 @@ componentTests('RegionSelect', (mount) => { mount( {}} + regions={[region]} + value={undefined} /> ); @@ -110,10 +111,10 @@ componentTests('RegionSelect', (mount) => { <> Other Element {}} + regions={[region]} + value={undefined} /> ); @@ -143,10 +144,10 @@ componentTests('RegionSelect', (mount) => { it('can select a region initially', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -177,10 +178,10 @@ componentTests('RegionSelect', (mount) => { it('can change region selection', () => { mount( {}} + regions={regions} + value={regionToPreselect.id} /> ); @@ -212,10 +213,10 @@ componentTests('RegionSelect', (mount) => { it('can clear region selection', () => { mount( {}} + regions={regions} + value={regionToSelect.id} /> ); @@ -238,11 +239,11 @@ componentTests('RegionSelect', (mount) => { it('cannot clear region selection when clearable is disabled', () => { mount( {}} + regions={regions} + value={regionToSelect.id} /> ); @@ -258,10 +259,10 @@ componentTests('RegionSelect', (mount) => { it('cannot clear region selection when no region is selected', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -275,10 +276,10 @@ componentTests('RegionSelect', (mount) => { const spyFn = createSpy(() => {}, 'changeSpy'); mount( ); @@ -299,10 +300,10 @@ componentTests('RegionSelect', (mount) => { const spyFn = createSpy(() => {}, 'changeSpy'); mount( ); @@ -343,10 +344,10 @@ componentTests('RegionSelect', (mount) => { // TODO Remove `dcGetWell` flag override when feature flag is removed from codebase. mount( {}} + regions={regions} + value={undefined} />, { dcGetWell: true, @@ -377,10 +378,10 @@ componentTests('RegionSelect', (mount) => { it('only lists regions with the specified capability', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -406,10 +407,10 @@ componentTests('RegionSelect', (mount) => { it('lists all regions when no capability is specified', () => { mount( {}} + regions={regions} + value={undefined} /> ); @@ -436,10 +437,10 @@ componentTests('RegionSelect', (mount) => { it('passes aXe check when menu is closed without an item selected', () => { mount( {}} + regions={regions} + value={undefined} /> ); checkComponentA11y(); @@ -448,10 +449,10 @@ componentTests('RegionSelect', (mount) => { it('passes aXe check when menu is closed with an item selected', () => { mount( {}} + regions={regions} + value={selectedRegion.id} /> ); checkComponentA11y(); @@ -460,10 +461,10 @@ componentTests('RegionSelect', (mount) => { it('passes aXe check when menu is open', () => { mount( {}} + regions={regions} + value={selectedRegion.id} /> ); diff --git a/packages/manager/cypress/component/poc/select.spec.tsx b/packages/manager/cypress/component/components/select.spec.tsx similarity index 100% rename from packages/manager/cypress/component/poc/select.spec.tsx rename to packages/manager/cypress/component/components/select.spec.tsx diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts index 950719ce399..22fd28b798a 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts @@ -2,22 +2,83 @@ import { authenticate } from 'support/api/authentication'; import { createDomain } from 'support/api/domains'; import { interceptCreateDomainRecord } from 'support/intercepts/domains'; import { createDomainRecords } from 'support/constants/domains'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; + +const createCaaRecord = ( + name: string, + tag: string, + value: string, + ttl: string +) => { + cy.findByText('Add a CAA Record').click(); + + // Fill in the form fields + cy.findByLabelText('Name').type(name); + + ui.autocomplete.findByLabel('Tag').click(); + ui.autocompletePopper.findByTitle(tag).click(); + + cy.findByLabelText('Value').type(value); + + ui.autocomplete.findByLabel('TTL').click(); + ui.autocompletePopper.findByTitle(ttl).click(); + + // Save the record + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); +}; + +// Reusable function to edit a CAA record +const editCaaRecord = (name: string, newValue: string) => { + ui.actionMenu + .findByTitle(`Action menu for Record ${name}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + + // Edit the value field + cy.findByLabelText('Value').clear().type(newValue); + ui.button.findByTitle('Save').click(); +}; + +// Reusable function to verify record details in the table +const verifyRecordInTable = ( + name: string, + tag: string, + value: string, + ttl: string +) => { + cy.get('[aria-label="List of Domains CAA Record"]') // Target table by aria-label + .should('contain', name) + .and('contain', tag) + .and('contain', value) + .and('contain', ttl); +}; authenticate(); + +before(() => { + cleanUp('domains'); +}); + beforeEach(() => { cy.tag('method:e2e'); + createDomain().then((domain) => { + // intercept create API record request + interceptCreateDomainRecord().as('apiCreateRecord'); + const url = `/domains/${domain.id}`; + cy.visitWithLogin(url); + cy.url().should('contain', url); + }); }); describe('Creates Domains records with Form', () => { it('Adds domain records to a newly created Domain', () => { - createDomain().then((domain) => { - // intercept create api record request - interceptCreateDomainRecord().as('apiCreateRecord'); - const url = `/domains/${domain.id}`; - cy.visitWithLogin(url); - cy.url().should('contain', url); - }); - createDomainRecords().forEach((rec) => { cy.findByText(rec.name).click(); rec.fields.forEach((field) => { @@ -36,3 +97,37 @@ describe('Creates Domains records with Form', () => { }); }); }); + +describe('Tests for Editable Domain CAA Records', () => { + beforeEach(() => { + // Create the initial record with a valid email + createCaaRecord( + 'securitytest', + 'iodef', + 'mailto:security@example.com', + '5 minutes' + ); + + // Verify the initial record is in the table + verifyRecordInTable( + 'securitytest', + 'iodef', + 'mailto:security@example.com', + '5 minutes' + ); + }); + + it('Validates that "iodef" domain records can be edited with valid record', () => { + // Edit the record with a valid email and verify the updated record + editCaaRecord('securitytest', 'mailto:secdef@example.com'); + cy.get('table').should('contain', 'mailto:secdef@example.com'); + }); + + it('Validates that "iodef" domain records returns error with invalid record', () => { + // Edit the record with invalid email and validate form validation + editCaaRecord('securitytest', 'invalid-email-format'); + cy.get('p[role="alert"][data-qa-textfield-error-text="Value"]') + .should('exist') + .and('have.text', 'You have entered an invalid target'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts index 8da716b8d33..69a637ff321 100644 --- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -24,7 +24,7 @@ describe('Search Images', () => { cy.defer( () => createTestLinode( - { image: 'linode/debian10', region: 'us-east' }, + { image: 'linode/debian12', region: 'us-east' }, { waitForDisks: true } ), 'create linode' diff --git a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index 1ee83565388..4c981b2f3ce 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -10,7 +10,7 @@ import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; describe('create image (using mocks)', () => { it('create image from a linode', () => { const mockDisks = [ - linodeDiskFactory.build({ label: 'Debian 10 Disk', filesystem: 'ext4' }), + linodeDiskFactory.build({ label: 'Debian 12 Disk', filesystem: 'ext4' }), linodeDiskFactory.build({ label: '512 MB Swap Image', filesystem: 'swap', diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index b97a4b77baf..e4eaa8c155b 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -3,6 +3,10 @@ import { mockGetClusters, mockGetClusterPools, mockGetKubeconfig, + mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, + mockRecycleAllNodes, + mockUpdateCluster, } from 'support/intercepts/lke'; import { accountFactory, @@ -165,4 +169,186 @@ describe('LKE landing page', () => { cy.wait('@getKubeconfig'); readDownload(mockKubeconfigFilename).should('eq', mockKubeconfigContents); }); + + it('does not show an Upgrade chip when there is no new kubernetes standard version', () => { + const oldVersion = '1.25'; + const newVersion = '1.26'; + + const cluster = kubernetesClusterFactory.build({ + k8s_version: newVersion, + }); + + mockGetClusters([cluster]).as('getClusters'); + mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getClusters', '@getVersions']); + + cy.findByText(newVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('not.exist'); + }); + + it('does not show an Upgrade chip when there is no new kubernetes enterprise version', () => { + const oldVersion = '1.31.1+lke1'; + const newVersion = '1.32.1+lke2'; + + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }); + + const cluster = kubernetesClusterFactory.build({ + k8s_version: newVersion, + tier: 'enterprise', + }); + + mockGetClusters([cluster]).as('getClusters'); + mockGetTieredKubernetesVersions('enterprise', [ + { id: newVersion, tier: 'enterprise' }, + { id: oldVersion, tier: 'enterprise' }, + ]).as('getTieredVersions'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getAccount', '@getClusters', '@getTieredVersions']); + + cy.findByText(newVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('not.exist'); + }); + + it('can upgrade the standard kubernetes version from the landing page', () => { + const oldVersion = '1.25'; + const newVersion = '1.26'; + + const cluster = kubernetesClusterFactory.build({ + k8s_version: oldVersion, + }); + + const updatedCluster = { ...cluster, k8s_version: newVersion }; + + mockGetClusters([cluster]).as('getClusters'); + mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); + mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getClusters', '@getVersions']); + + cy.findByText(oldVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('be.visible').should('be.enabled').click(); + + ui.dialog + .findByTitle( + `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + ) + .should('be.visible'); + + mockGetClusters([updatedCluster]).as('getClusters'); + + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@updateCluster', '@getClusters']); + + ui.dialog + .findByTitle('Step 2: Recycle All Cluster Nodes') + .should('be.visible'); + + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@recycleAllNodes'); + + ui.toast.assertMessage('Recycle started successfully.'); + + cy.findByText(newVersion).should('be.visible'); + }); + + it('can upgrade the enterprise kubernetes version from the landing page', () => { + const oldVersion = '1.31.1+lke1'; + const newVersion = '1.32.1+lke2'; + + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }); + + const cluster = kubernetesClusterFactory.build({ + k8s_version: oldVersion, + tier: 'enterprise', + }); + + const updatedCluster = { ...cluster, k8s_version: newVersion }; + + mockGetClusters([cluster]).as('getClusters'); + mockGetTieredKubernetesVersions('enterprise', [ + { id: newVersion, tier: 'enterprise' }, + { id: oldVersion, tier: 'enterprise' }, + ]).as('getTieredVersions'); + mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); + mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); + + cy.visitWithLogin(`/kubernetes/clusters`); + + cy.wait(['@getAccount', '@getClusters', '@getTieredVersions']); + + cy.findByText(oldVersion).should('be.visible'); + + cy.findByText('UPGRADE').should('be.visible').should('be.enabled').click(); + + ui.dialog + .findByTitle( + `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + ) + .should('be.visible'); + + mockGetClusters([updatedCluster]).as('getClusters'); + + ui.button + .findByTitle('Upgrade Version') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@updateCluster', '@getClusters']); + + ui.dialog + .findByTitle('Step 2: Recycle All Cluster Nodes') + .should('be.visible'); + + ui.button + .findByTitle('Recycle All Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@recycleAllNodes'); + + ui.toast.assertMessage('Recycle started successfully.'); + + cy.findByText(newVersion).should('be.visible'); + }); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 07961e4b0ce..788f19e2d31 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -25,12 +25,12 @@ import { mockRecycleAllNodes, mockGetDashboardUrl, mockGetApiEndpoints, - mockGetClusters, mockUpdateControlPlaneACL, mockGetControlPlaneACL, mockUpdateControlPlaneACLError, mockGetControlPlaneACLError, mockGetTieredKubernetesVersions, + mockUpdateClusterError, } from 'support/intercepts/lke'; import { mockGetLinodeType, @@ -238,65 +238,6 @@ describe('LKE cluster updates', () => { ui.toast.findByMessage('Recycle started successfully.'); }); - it('can upgrade the standard kubernetes version from the landing page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; - - const cluster = kubernetesClusterFactory.build({ - k8s_version: oldVersion, - }); - - const updatedCluster = { ...cluster, k8s_version: newVersion }; - - mockGetClusters([cluster]).as('getClusters'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); - mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); - mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); - - cy.visitWithLogin(`/kubernetes/clusters`); - - cy.wait(['@getClusters', '@getVersions']); - - cy.findByText(oldVersion).should('be.visible'); - - cy.findByText('UPGRADE') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle( - `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` - ) - .should('be.visible'); - - mockGetClusters([updatedCluster]).as('getClusters'); - - ui.button - .findByTitle('Upgrade Version') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait(['@updateCluster', '@getClusters']); - - ui.dialog - .findByTitle('Step 2: Recycle All Cluster Nodes') - .should('be.visible'); - - ui.button - .findByTitle('Recycle All Nodes') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@recycleAllNodes'); - - ui.toast.assertMessage('Recycle started successfully.'); - - cy.findByText(newVersion).should('be.visible'); - }); - /* * - Confirms UI flow of upgrading Kubernetes enterprise version using mocked API requests. * - Confirms that Kubernetes upgrade prompt is shown when not up-to-date. @@ -425,80 +366,6 @@ describe('LKE cluster updates', () => { ui.toast.findByMessage('Recycle started successfully.'); }); - it('can upgrade the enterprise kubernetes version from the landing page', () => { - const oldVersion = '1.31.1+lke1'; - const newVersion = '1.32.1+lke2'; - - mockGetAccount( - accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], - }) - ).as('getAccount'); - - // TODO LKE-E: Remove once feature is in GA - mockAppendFeatureFlags({ - lkeEnterprise: { enabled: true, la: true }, - }); - - const cluster = kubernetesClusterFactory.build({ - k8s_version: oldVersion, - tier: 'enterprise', - }); - - const updatedCluster = { ...cluster, k8s_version: newVersion }; - - mockGetClusters([cluster]).as('getClusters'); - mockGetTieredKubernetesVersions('enterprise', [ - { id: newVersion, tier: 'enterprise' }, - { id: oldVersion, tier: 'enterprise' }, - ]).as('getTieredVersions'); - mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); - mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); - - cy.visitWithLogin(`/kubernetes/clusters`); - - cy.wait(['@getAccount', '@getClusters', '@getTieredVersions']); - - cy.findByText(oldVersion).should('be.visible'); - - cy.findByText('UPGRADE') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle( - `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` - ) - .should('be.visible'); - - mockGetClusters([updatedCluster]).as('getClusters'); - - ui.button - .findByTitle('Upgrade Version') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait(['@updateCluster', '@getClusters']); - - ui.dialog - .findByTitle('Step 2: Recycle All Cluster Nodes') - .should('be.visible'); - - ui.button - .findByTitle('Recycle All Nodes') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@recycleAllNodes'); - - ui.toast.assertMessage('Recycle started successfully.'); - - cy.findByText(newVersion).should('be.visible'); - }); - /* * - Confirms node, node pool, and cluster recycling UI flow using mocked API data. * - Confirms that user is warned that recycling recreates nodes and may take a while. @@ -1063,6 +930,74 @@ describe('LKE cluster updates', () => { .should('be.visible') .should('be.disabled'); }); + + /* + * - Confirms LKE summary page updates to reflect new cluster name. + */ + it('can rename cluster', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + const mockNewCluster = kubernetesClusterFactory.build({ + label: 'newClusterName', + }); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockUpdateCluster(mockCluster.id, mockNewCluster).as('updateCluster'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // LKE clusters can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. + cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); + cy.findByTestId('textfield-input') + .should('be.visible') + .should('have.value', mockCluster.label) + .clear() + .type(`${mockNewCluster.label}{enter}`); + + cy.wait('@updateCluster'); + + cy.findAllByText(mockNewCluster.label).should('be.visible'); + cy.findAllByText(mockCluster.label).should('not.exist'); + }); + + /* + * - Confirms error message shows when the API request fails. + */ + it('can handle API errors when renaming cluster', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + const mockErrorCluster = kubernetesClusterFactory.build({ + label: 'errorClusterName', + }); + const mockErrorMessage = 'API request fails'; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockUpdateClusterError(mockCluster.id, mockErrorMessage).as( + 'updateClusterError' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + + // LKE cluster can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. + cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); + cy.findByTestId('textfield-input') + .should('be.visible') + .should('have.value', mockCluster.label) + .clear() + .type(`${mockErrorCluster.label}{enter}`); + + // Error message shows when API request fails. + cy.wait('@updateClusterError'); + cy.findAllByText(mockErrorMessage).should('be.visible'); + }); }); it('can add and delete node pool tags', () => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts index 0ae05e81ecc..e15151274c0 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts @@ -20,7 +20,7 @@ describe('Create Linode flow to validate code snippet modal', () => { // Set Linode label, distribution, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById('us-east'); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(rootPass); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts index c6d8befe082..2b6399144be 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -29,7 +29,7 @@ describe('Create Linode with Add-ons', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -83,7 +83,7 @@ describe('Create Linode with Add-ons', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index 70a759c140a..daf52a7b707 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -45,7 +45,7 @@ describe('Create Linode with Firewall', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -111,7 +111,7 @@ describe('Create Linode with Firewall', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -218,7 +218,7 @@ describe('Create Linode with Firewall', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts index b613f8cf384..dd9b1fa1153 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -41,7 +41,7 @@ describe('Create Linode with SSH Key', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -107,7 +107,7 @@ describe('Create Linode with SSH Key', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts index 8951ed66e9b..009aa74305d 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -34,7 +34,7 @@ describe('Create Linode with user data', () => { // Fill out create form, selecting a region and image that both have // cloud-init capabilities. linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -88,7 +88,7 @@ describe('Create Linode with user data', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(mockLinodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index d80d2a963b9..270b9de1072 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -42,7 +42,7 @@ describe('Create Linode with VLANs', () => { // Fill out necessary Linode create fields. linodeCreatePage.selectRegionById(mockLinodeRegion.id); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.setLabel(mockLinode.label); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -130,7 +130,7 @@ describe('Create Linode with VLANs', () => { // Fill out necessary Linode create fields. linodeCreatePage.selectRegionById(mockLinodeRegion.id); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.setLabel(mockLinode.label); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index 524519b6832..08842e8f196 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -90,7 +90,7 @@ describe('Create Linode with VPCs', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); @@ -214,7 +214,7 @@ describe('Create Linode with VPCs', () => { cy.visitWithLogin('/linodes/create'); linodeCreatePage.setLabel(mockLinode.label); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); 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 f60a7b5941f..37e5309715d 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -104,7 +104,7 @@ describe('Create Linode', () => { // Set Linode label, OS, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan( planConfig.planType, @@ -116,7 +116,7 @@ describe('Create Linode', () => { cy.get('[data-qa-linode-create-summary]') .scrollIntoView() .within(() => { - cy.findByText('Debian 11').should('be.visible'); + cy.findByText('Debian 12').should('be.visible'); cy.findByText(linodeRegion.label).should('be.visible'); cy.findByText(planConfig.planLabel).should('be.visible'); }); @@ -230,7 +230,7 @@ describe('Create Linode', () => { // Set Linode label, OS, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Accelerated', mockAcceleratedType[0].label); linodeCreatePage.setRootPassword(randomString(32)); @@ -239,7 +239,7 @@ describe('Create Linode', () => { cy.get('[data-qa-linode-create-summary]') .scrollIntoView() .within(() => { - cy.findByText('Debian 11').should('be.visible'); + cy.findByText('Debian 12').should('be.visible'); cy.findByText(`US, ${linodeRegion.label}`).should('be.visible'); cy.findByText(mockAcceleratedType[0].label).should('be.visible'); }); @@ -462,7 +462,7 @@ describe('Create Linode', () => { // Set Linode label, OS, plan type, password, etc. linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectImage('Debian 12'); linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 88ff834b419..79268f5d555 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -208,7 +208,7 @@ describe('linode storage tab', () => { * - Confirms that Cloud Manager UI automatically updates to reflect resize. */ it('resize disk', () => { - const diskName = 'Debian 10 Disk'; + const diskName = 'Debian 12 Disk'; cy.defer(() => createTestLinode({ image: null }, { securityMethod: 'powered_off' }) ).then((linode: Linode) => { diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 5cfce4ac762..7d3b490d032 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -128,9 +128,9 @@ const fillOutLinodeForm = (label: string, regionName: string) => { * @returns Promise that resolves to the new Image. */ const createLinodeAndImage = async () => { - // 1.5GB - // Shout out to Debian for fitting on a 1.5GB disk. - const resizedDiskSize = 1536; + // 2GB + // Shout out to Debian for fitting on a 2GB disk. + const resizedDiskSize = 2048; const linode = await createTestLinode( createLinodeRequestFactory.build({ label: randomLabel(), diff --git a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts index e65a582243b..146233cae6c 100644 --- a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts @@ -1,14 +1,11 @@ -import type { VolumeRequestPayload } from '@linode/api-v4'; -import { createVolume } from '@linode/api-v4/lib/volumes'; import { Volume } from '@linode/api-v4'; import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; import { interceptCloneVolume } from 'support/intercepts/volumes'; -import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; -import { pollVolumeStatus } from 'support/util/polling'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { createActiveVolume } from 'support/api/volumes'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -17,19 +14,6 @@ const pageSizeOverride = { PAGE_SIZE: 100, }; -/** - * Creates a Volume and waits for it to become active. - * - * @param volumeRequest - Volume create request payload. - * - * @returns Promise that resolves to created Volume. - */ -const createActiveVolume = async (volumeRequest: VolumeRequestPayload) => { - const volume = await createVolume(volumeRequest); - await pollVolumeStatus(volume.id, 'active', new SimpleBackoffMethod(10000)); - return volume; -}; - authenticate(); describe('volume clone flow', () => { before(() => { diff --git a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts index 70f4840bbad..25b5e128405 100644 --- a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts @@ -1,10 +1,12 @@ -import { createVolume } from '@linode/api-v4/lib/volumes'; import { Volume } from '@linode/api-v4'; + import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; +import { ui } from 'support/ui'; +import { createActiveVolume } from 'support/api/volumes'; authenticate(); describe('volume update flow', () => { @@ -16,18 +18,17 @@ describe('volume update flow', () => { }); /* - * - Confirms that volume label and tags can be changed from the Volumes landing page. + * - Confirms that volume label can be changed from the Volumes landing page. */ - it("updates a volume's label and tags", () => { + it("updates a volume's label", () => { const volumeRequest = volumeRequestPayloadFactory.build({ label: randomLabel(), region: chooseRegion().id, }); const newLabel = randomLabel(); - const newTags = [randomLabel(5), randomLabel(5), randomLabel(5)]; - cy.defer(() => createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { cy.visitWithLogin('/volumes', { // Temporarily force volume table to show up to 100 results per page. @@ -43,10 +44,15 @@ describe('volume update flow', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText('Edit').click(); + cy.findByText('active').should('be.visible'); }); + ui.actionMenu + .findByTitle(`Action menu for Volume ${volume.label}`) + .should('be.visible') + .click(); + cy.get('[data-testid="Edit"]').click(); - // Enter new label and add tags, click "Save Changes". + // Enter new label, click "Save Changes". cy.get('[data-qa-drawer="true"]').within(() => { cy.findByText('Edit Volume').should('be.visible'); cy.findByDisplayValue(volume.label) @@ -54,6 +60,67 @@ describe('volume update flow', () => { .click() .type(`{selectall}{backspace}${newLabel}`); + cy.findByText('Save Changes').should('be.visible').click(); + }); + + // Confirm new label is applied, click "Edit" to re-open drawer. + cy.findByText(newLabel).should('be.visible'); + ui.actionMenu + .findByTitle(`Action menu for Volume ${newLabel}`) + .should('be.visible') + .click(); + cy.get('[data-testid="Edit"]').click(); + + // Confirm new label is shown. + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByText('Edit Volume').should('be.visible'); + cy.findByDisplayValue(newLabel).should('be.visible'); + }); + } + ); + }); + + /* + * - Confirms that volume tags can be changed from the Volumes landing page. + */ + it("updates volume's tags", () => { + const volumeRequest = volumeRequestPayloadFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const newTags = [randomLabel(5), randomLabel(5), randomLabel(5)]; + + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( + (volume: Volume) => { + cy.visitWithLogin('/volumes', { + // Temporarily force volume table to show up to 100 results per page. + // This is a workaround while we wait to get stuck volumes removed. + // @TODO Remove local storage override when stuck volumes are removed from test accounts. + localStorageOverrides: { + PAGE_SIZE: 100, + }, + }); + + // Confirm that volume is listed on landing page, click "Edit" to open drawer. + cy.findByText(volume.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('active').should('be.visible'); + }); + + ui.actionMenu + .findByTitle(`Action menu for Volume ${volume.label}`) + .should('be.visible') + .click(); + + cy.get('[data-testid="Manage Tags"]').click(); + + // Add tags, click "Save Changes". + cy.get('[data-qa-drawer="true"]').within(() => { + cy.findByText('Manage Volume Tags').should('be.visible'); + cy.findByPlaceholderText('Type to choose or create a tag.') .should('be.visible') .click() @@ -62,18 +129,18 @@ describe('volume update flow', () => { cy.findByText('Save Changes').should('be.visible').click(); }); - // Confirm new label is applied, click "Edit" to re-open drawer. - cy.findByText(newLabel) + // Confirm new tags are shown, click "Manage Volume Tags" to re-open drawer. + cy.findByText(volumeRequest.label).should('be.visible'); + + ui.actionMenu + .findByTitle(`Action menu for Volume ${volume.label}`) .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Edit').click(); - }); + .click(); + + cy.get('[data-testid="Manage Tags"]').click(); - // Confirm new label and tags are shown. cy.get('[data-qa-drawer="true"]').within(() => { - cy.findByText('Edit Volume').should('be.visible'); - cy.findByDisplayValue(newLabel).should('be.visible'); + cy.findByText('Manage Volume Tags').should('be.visible'); // Click the tags input field to see all the selected tags cy.findByRole('combobox').should('be.visible').click(); @@ -85,4 +152,8 @@ describe('volume update flow', () => { } ); }); + + after(() => { + cleanUp(['tags', 'volumes']); + }); }); diff --git a/packages/manager/cypress/support/api/volumes.ts b/packages/manager/cypress/support/api/volumes.ts index a52e4784f13..0ef03c9d816 100644 --- a/packages/manager/cypress/support/api/volumes.ts +++ b/packages/manager/cypress/support/api/volumes.ts @@ -1,8 +1,17 @@ -import { Volume, deleteVolume, detachVolume, getVolumes } from '@linode/api-v4'; +import { + createVolume, + deleteVolume, + detachVolume, + getVolumes, +} from '@linode/api-v4'; import { pageSize } from 'support/constants/api'; +import { SimpleBackoffMethod, attemptWithBackoff } from 'support/util/backoff'; import { depaginate } from 'support/util/paginate'; +import { pollVolumeStatus } from 'support/util/polling'; + import { isTestLabel } from './common'; -import { attemptWithBackoff, SimpleBackoffMethod } from 'support/util/backoff'; + +import type { Volume, VolumeRequestPayload } from '@linode/api-v4'; /** * Delete all Volumes whose labels are prefixed "cy-test-". @@ -45,3 +54,18 @@ export const deleteAllTestVolumes = async (): Promise => { await Promise.all(detachDeletePromises); }; + +/** + * Creates a Volume and waits for it to become active. + * + * @param volumeRequest - Volume create request payload. + * + * @returns Promise that resolves to created Volume. + */ +export const createActiveVolume = async ( + volumeRequest: VolumeRequestPayload +) => { + const volume = await createVolume(volumeRequest); + await pollVolumeStatus(volume.id, 'active', new SimpleBackoffMethod(10000)); + return volume; +}; diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 6f30bdeda1e..88905b33b38 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -508,3 +508,24 @@ export const mockGetLKEClusterTypes = ( ): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('lke/types*'), paginateResponse(types)); }; + +/** + * Intercepts PUT request to update an LKE cluster and mocks an error response. + * + * @param clusterId - ID of cluster for which to intercept PUT request. + * @param errorMessage - Optional error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateClusterError = ( + clusterId: number, + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`lke/clusters/${clusterId}`), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/package.json b/packages/manager/package.json index 4643f2f7d06..71fd16f373f 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -96,6 +96,7 @@ "build:analyze": "bunx vite-bundle-visualizer", "precommit": "lint-staged && yarn typecheck", "test": "vitest run", + "test:ui": "vitest --ui", "test:debug": "node --inspect-brk scripts/test.js --runInBand", "storybook": "NODE_OPTIONS='--max-old-space-size=4096' storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index 2fed57bf4d9..dbeaedb1988 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -5,6 +5,8 @@ import * as React from 'react'; import { usePreferences } from 'src/queries/profile/preferences'; import { createMaskedText } from 'src/utilities/createMaskedText'; +import type { SxProps, Theme } from '@mui/material'; + export type MaskableTextLength = 'ipv4' | 'ipv6' | 'plaintext'; export interface MaskableTextProps { @@ -12,14 +14,28 @@ export interface MaskableTextProps { * (Optional) original JSX element to render if the text is not masked. */ children?: JSX.Element | JSX.Element[]; + /** + * Optionally specifies the position of the VisibilityTooltip icon either before or after the text. + * @default end + */ + iconPosition?: 'end' | 'start'; /** * If true, displays a VisibilityTooltip icon to toggle the masked and unmasked text. + * @default false */ isToggleable?: boolean; /** * Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length. */ length?: MaskableTextLength; + /** + * Optional styling for the masked and unmasked Typography + */ + sxTypography?: SxProps; + /** + * Optional styling for VisibilityTooltip icon + */ + sxVisibilityTooltip?: SxProps; /** * The original, maskable text; if the text is not masked, render this text or the styled text via children. */ @@ -27,7 +43,15 @@ export interface MaskableTextProps { } export const MaskableText = (props: MaskableTextProps) => { - const { children, isToggleable = false, length, text } = props; + const { + children, + iconPosition = 'end', + isToggleable = false, + length, + sxTypography, + sxVisibilityTooltip, + text, + } = props; const { data: maskedPreferenceSetting } = usePreferences( (preferences) => preferences?.maskSensitiveData @@ -35,7 +59,11 @@ export const MaskableText = (props: MaskableTextProps) => { const [isMasked, setIsMasked] = React.useState(maskedPreferenceSetting); - const unmaskedText = children ? children : {text}; + const unmaskedText = children ? ( + children + ) : ( + {text} + ); // Return early based on the preference setting and the original text. @@ -54,14 +82,25 @@ export const MaskableText = (props: MaskableTextProps) => { flexDirection="row" justifyContent="flex-start" > + {iconPosition === 'start' && isToggleable && ( + setIsMasked(!isMasked)} + isVisible={!isMasked} + /> + )} {isMasked ? ( - + {createMaskedText(text, length)} ) : ( unmaskedText )} - {isToggleable && ( + {iconPosition === 'end' && isToggleable && ( setIsMasked(!isMasked)} isVisible={!isMasked} diff --git a/packages/manager/src/components/MaskableText/MaskableTextArea.tsx b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx new file mode 100644 index 00000000000..abbeb85697a --- /dev/null +++ b/packages/manager/src/components/MaskableText/MaskableTextArea.tsx @@ -0,0 +1,18 @@ +import { Typography } from '@linode/ui'; +import React from 'react'; + +import { Link } from '../Link'; + +/** + * This copy is intended to display where a larger area of data is masked. + * Example: Billing Contact info, rather than masking many individual fields + */ +export const MaskableTextAreaCopy = () => { + return ( + + This data is sensitive and hidden for privacy. To unmask all sensitive + data by default, go to{' '} + profile settings. + + ); +}; diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts index f77ea890b71..563a50e194d 100644 --- a/packages/manager/src/dev-tools/load.ts +++ b/packages/manager/src/dev-tools/load.ts @@ -87,6 +87,7 @@ export async function loadDevTools( // Merge the contexts const mergedContext: MockState = { ...initialContext, + domains: [...initialContext.domains, ...(seedContext?.domains || [])], eventQueue: [ ...initialContext.eventQueue, ...(seedContext?.eventQueue || []), diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index b2e14153965..b6c0e921ccf 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -287,7 +287,7 @@ export const linodeFactory = Factory.Sync.makeFactory({ group: '', hypervisor: 'kvm', id: Factory.each((i) => i), - image: 'linode/debian10', + image: 'linode/debian12', ipv4: ['50.116.6.212', '192.168.203.1'], ipv6: '2600:3c00::f03c:92ff:fee2:6c40/64', label: Factory.each((i) => `linode-${i}`), @@ -309,7 +309,7 @@ export const linodeFactory = Factory.Sync.makeFactory({ export const createLinodeRequestFactory = Factory.Sync.makeFactory( { booted: true, - image: 'linode/debian10', + image: 'linode/debian12', label: Factory.each((i) => `linode-${i}`), region: 'us-southeast', root_pass: 'linode-root-password', diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index 8fd5b6c4157..2c85cd0b195 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useState } from 'react'; import { useHistory, useRouteMatch } from 'react-router-dom'; -import { Link } from 'src/components/Link'; +import { MaskableTextAreaCopy } from 'src/components/MaskableText/MaskableTextArea'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { EDIT_BILLING_CONTACT } from 'src/features/Billing/constants'; import { StyledAutorenewIcon } from 'src/features/TopMenu/NotificationMenu/NotificationMenu'; @@ -198,11 +198,7 @@ export const ContactInformation = React.memo((props: Props) => { {maskSensitiveDataPreference && isContactInfoMasked ? ( - - This data is sensitive and hidden for privacy. To unmask all - sensitive data by default, go to{' '} - profile settings. - + ) : ( {(firstName || diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 0e393915376..8df01ae47cc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -10,6 +10,7 @@ import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; +import { TriggerConditions } from './Criteria/TriggerConditions'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; import { EngineOption } from './GeneralInformation/EngineOption'; import { CloudPulseRegionSelect } from './GeneralInformation/RegionSelect'; @@ -18,14 +19,17 @@ import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect' import { CreateAlertDefinitionFormSchema } from './schemas'; import { filterFormValues } from './utilities'; -import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; -import type { TriggerCondition } from '@linode/api-v4/lib/cloudpulse/types'; +import type { + CreateAlertDefinitionForm, + MetricCriteriaForm, + TriggerConditionForm, +} from './types'; import type { ObjectSchema } from 'yup'; -const triggerConditionInitialValues: TriggerCondition = { +const triggerConditionInitialValues: TriggerConditionForm = { criteria_condition: 'ALL', - evaluation_period_seconds: 0, - polling_interval_seconds: 0, + evaluation_period_seconds: null, + polling_interval_seconds: null, trigger_occurrences: 0, }; const criteriaInitialValues: MetricCriteriaForm = { @@ -164,8 +168,10 @@ export const CreateAlertDefinition = () => { name="rule_criteria.rules" serviceType={serviceTypeWatcher!} /> - {/* This is just being displayed to pass the typecheck-manager test. In the next PR maxScrapeInterval will be used by another component */} - {maxScrapeInterval} + { + const user = userEvent.setup(); + + it('render the fields properly', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + expect(screen.getByText('Dimension Filter')).toBeVisible(); + expect(screen.getByText('(optional)')).toBeVisible(); + await user.click( + container.getByRole('button', { name: 'Add dimension filter' }) + ); + expect(screen.getByLabelText('Data Field')).toBeVisible(); + expect(screen.getByLabelText('Operator')).toBeVisible(); + expect(screen.getByLabelText('Value')).toBeVisible(); + }); + + it('does not render the dimension filed directly with Metric component', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const dimensionFilterID = 'rule_criteria.rules.0.dimension_filters.0-id'; + expect(container.queryByTestId(dimensionFilterID)).not.toBeInTheDocument(); + }); + + it('adds and removes dimension filter fields dynamically', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + + const dimensionFilterID = 'rule_criteria.rules.0.dimension_filters.1-id'; + await user.click( + container.getByRole('button', { name: dimensionFilterButton }) + ); + await user.click( + container.getByRole('button', { name: dimensionFilterButton }) + ); + expect(container.getByTestId(dimensionFilterID)).toBeInTheDocument(); + await user.click( + within(container.getByTestId(dimensionFilterID)).getByTestId('clear-icon') + ); + await waitFor(() => + expect(container.queryByTestId(dimensionFilterID)).not.toBeInTheDocument() + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx new file mode 100644 index 00000000000..a9a05303102 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx @@ -0,0 +1,70 @@ +import { Box } from '@linode/ui'; +import { Button, Stack, Typography } from '@linode/ui'; +import React from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; + +import { DimensionFilterField } from './DimensionFilterField'; + +import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; +import type { Dimension } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface DimensionFilterProps { + /** + * boolean value to disable the Data Field in dimension filter + */ + dataFieldDisabled: boolean; + /** + * dimension filter data for the selected metric + */ + dimensionOptions: Dimension[]; + /** + * name used for the component to set in the form + */ + name: FieldPathByValue; +} +export const DimensionFilters = (props: DimensionFilterProps) => { + const { dataFieldDisabled, dimensionOptions, name } = props; + const { control } = useFormContext(); + + const { append, fields, remove } = useFieldArray({ + control, + name, + }); + return ( + + + Dimension Filter + (optional) + + + + {fields?.length > 0 && + fields.map((field, index) => ( + remove(index)} + /> + ))} + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx new file mode 100644 index 00000000000..c5085cb365b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx @@ -0,0 +1,228 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { DimensionOperatorOptions } from '../../constants'; +import { DimensionFilterField } from './DimensionFilterField'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { MetricDefinition } from '@linode/api-v4'; + +const mockData: MetricDefinition[] = [ + { + available_aggregate_functions: ['min', 'max', 'avg'], + dimensions: [ + { + dimension_label: 'cpu', + label: 'CPU name', + values: [], + }, + { + dimension_label: 'state', + label: 'State of CPU', + values: [ + 'user', + 'system', + 'idle', + 'interrupt', + 'nice', + 'softirq', + 'steal', + 'wait', + ], + }, + { + dimension_label: 'LINODE_ID', + label: 'Linode ID', + values: [], + }, + ], + label: 'CPU utilization', + metric: 'system_cpu_utilization_percent', + metric_type: 'gauge', + scrape_interval: '2m', + unit: 'percent', + }, +]; + +const dimensionFieldMockData = mockData[0].dimensions; +describe('Dimension filter field component', () => { + const user = userEvent.setup(); + it('should render all the components and names', () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + }); + expect(screen.getByLabelText('Data Field')).toBeVisible(); + expect(screen.getByLabelText('Operator')).toBeVisible(); + expect(screen.getByLabelText('Value')).toBeVisible(); + }); + + it('should render the Data Field component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const dataFieldContainer = container.getByTestId('data-field'); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await user.click(dataFieldInput); + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[0].label, + }) + ).toBeInTheDocument(); + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[1].label, + }) + ).toBeInTheDocument(); + await user.click( + container.getByRole('option', { name: dimensionFieldMockData[0].label }) + ); + expect(within(dataFieldContainer).getByRole('combobox')).toHaveAttribute( + 'value', + dimensionFieldMockData[0].label + ); + }); + + it('should render the Operator component', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const operatorContainer = container.getByTestId('operator'); + const operatorInput = within(operatorContainer).getByRole('button', { + name: 'Open', + }); + + user.click(operatorInput); + + expect( + await container.findByRole('option', { + name: DimensionOperatorOptions[1].label, + }) + ); + + await user.click( + await container.findByRole('option', { + name: DimensionOperatorOptions[0].label, + }) + ); + + expect(within(operatorContainer).getByRole('combobox')).toHaveAttribute( + 'value', + DimensionOperatorOptions[0].label + ); + }); + + it('should render the Value component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], + }, + serviceType: 'linode', + }, + }, + } + ); + const dataFieldContainer = container.getByTestId('data-field'); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await user.click(dataFieldInput); + await user.click( + await container.findByRole('option', { + name: dimensionFieldMockData[1].label, + }) + ); + const valueContainer = container.getByTestId('value'); + const valueInput = within(valueContainer).getByRole('button', { + name: 'Open', + }); + + user.click(valueInput); + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[1].values[0], + }) + ); + + expect( + await container.findByRole('option', { + name: dimensionFieldMockData[1].values[1], + }) + ); + + await user.click( + container.getByRole('option', { + name: dimensionFieldMockData[1].values[0], + }) + ); + + expect(within(valueContainer).getByRole('combobox')).toHaveAttribute( + 'value', + dimensionFieldMockData[1].values[0] + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx new file mode 100644 index 00000000000..b0b877c3bf0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -0,0 +1,186 @@ +import { Autocomplete, Box } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { DimensionOperatorOptions } from '../../constants'; +import { ClearIconButton } from './ClearIconButton'; + +import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; +import type { Dimension, DimensionFilterOperatorType } from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; + +interface DimensionFilterFieldProps { + /** + * boolean value to disable the Data Field in dimension filter + */ + dataFieldDisabled: boolean; + /** + * dimension filter data options to list in the Autocomplete component + */ + dimensionOptions: Dimension[]; + /** + * name (with the index) used for the component to set in form + */ + name: FieldPathByValue; + /** + * function to delete the DimensionFilter component + * @returns void + */ + onFilterDelete: () => void; +} + +export const DimensionFilterField = (props: DimensionFilterFieldProps) => { + const { dataFieldDisabled, dimensionOptions, name, onFilterDelete } = props; + + const { control, setValue } = useFormContext(); + + const dataFieldOptions = + dimensionOptions.map((dimension) => ({ + label: dimension.label, + value: dimension.dimension_label, + })) ?? []; + + const handleDataFieldChange = ( + selected: { label: string; value: string }, + operation: string + ) => { + const fieldValue = { + dimension_label: null, + operator: null, + value: null, + }; + setValue( + name, + operation === 'selectOption' + ? { ...fieldValue, dimension_label: selected.value } + : fieldValue, + { shouldValidate: true } + ); + }; + + const dimensionFieldWatcher = useWatch({ + control, + name: `${name}.dimension_label`, + }); + + const selectedDimension = + dimensionOptions && dimensionFieldWatcher + ? dimensionOptions.find( + (dim) => dim.dimension_label === dimensionFieldWatcher + ) ?? null + : null; + + const valueOptions = () => { + if (selectedDimension !== null) { + return selectedDimension.values.map((val) => ({ + label: val, + value: val, + })); + } + return []; + }; + + return ( + + + ( + { + handleDataFieldChange(newValue, operation); + }} + value={ + dataFieldOptions.find( + (option) => option.value === field.value + ) ?? null + } + data-testid="data-field" + disabled={dataFieldDisabled} + errorText={fieldState.error?.message} + label="Data Field" + onBlur={field.onBlur} + options={dataFieldOptions} + placeholder="Select a Data field" + /> + )} + control={control} + name={`${name}.dimension_label`} + /> + + + ( + { + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); + }} + value={ + DimensionOperatorOptions.find( + (option) => option.value === field.value + ) ?? null + } + data-testid="operator" + errorText={fieldState.error?.message} + label="Operator" + onBlur={field.onBlur} + options={DimensionOperatorOptions} + /> + )} + control={control} + name={`${name}.operator`} + /> + + + + ( + + option.value === value.value + } + onChange={( + _, + selected: { label: string; value: string }, + operation + ) => { + field.onChange( + operation === 'selectOption' ? selected.value : null + ); + }} + value={ + valueOptions().find( + (option) => option.value === field.value + ) ?? null + } + data-testid="value" + disabled={!dimensionFieldWatcher} + errorText={fieldState.error?.message} + label="Value" + onBlur={field.onBlur} + options={valueOptions()} + placeholder="Select a Value" + sx={{ flex: 1 }} + /> + )} + control={control} + name={`${name}.value`} + /> + + + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx index dfd3cea1995..3de387b7319 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx @@ -91,7 +91,7 @@ describe('Metric component tests', () => { }, } ); - const dataFieldContainer = container.getByTestId('Data-field'); + const dataFieldContainer = container.getByTestId('data-field'); expect( within(dataFieldContainer).getByRole('button', { name: @@ -140,7 +140,7 @@ describe('Metric component tests', () => { } ); - const aggregationTypeContainer = container.getByTestId('Aggregation-type'); + const aggregationTypeContainer = container.getByTestId('aggregation-type'); const aggregationTypeInput = within( aggregationTypeContainer ).getByRole('button', { name: 'Open' }); @@ -185,7 +185,7 @@ describe('Metric component tests', () => { }, } ); - const operatorContainer = container.getByTestId('Operator'); + const operatorContainer = container.getByTestId('operator'); const operatorInput = within(operatorContainer).getByRole('button', { name: 'Open', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx index 5a4d317237c..6851a6e53c8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -1,5 +1,5 @@ import { Autocomplete, Box } from '@linode/ui'; -import { Stack, TextField, Typography } from '@linode/ui'; +import { TextField, Typography } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -9,12 +9,13 @@ import { MetricOperatorOptions, } from '../../constants'; import { ClearIconButton } from './ClearIconButton'; +import { DimensionFilters } from './DimensionFilter'; import type { Item } from '../../constants'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from '../types'; import type { - MetricDefinition, MetricAggregationType, + MetricDefinition, MetricOperatorType, } from '@linode/api-v4'; import type { FieldPathByValue } from 'react-hook-form'; @@ -68,15 +69,13 @@ export const Metric = (props: MetricCriteriaProps) => { operator: null, threshold: 0, }; - if (operation === 'selectOption') { - setValue(name, { - ...fieldValue, - metric: selected.value, - }); - } - if (operation === 'clear') { - setValue(name, fieldValue); - } + setValue( + name, + operation === 'selectOption' + ? { ...fieldValue, metric: selected.value } + : fieldValue, + { shouldValidate: true } + ); }; const metricOptions = React.useMemo(() => { @@ -113,20 +112,24 @@ export const Metric = (props: MetricCriteriaProps) => { ({ backgroundColor: - theme.name === 'light' ? theme.color.grey5 : theme.color.grey9, + theme.name === 'light' + ? theme.tokens.color.Neutrals[5] + : theme.tokens.color.Neutrals.Black, borderRadius: 1, + display: 'flex', + flexDirection: 'column', + gap: 2, p: 2, })} data-testid={`${name}-id`} > - + Metric Threshold - - {showDeleteIcon && } - + {showDeleteIcon && } - + + ( @@ -152,16 +155,15 @@ export const Metric = (props: MetricCriteriaProps) => { 'Represents the metric you want to receive alerts for. Choose the one that helps you evaluate performance of your service in the most efficient way.', }} value={ - field.value !== null - ? metricOptions.find( - (option) => option.value === field.value - ) - : null + metricOptions.find( + (option) => option.value === field.value + ) ?? null } - data-testid="Data-field" + data-testid="data-field" disabled={!serviceWatcher} label="Data Field" loading={isMetricDefinitionLoading} + noMarginTop onBlur={field.onBlur} options={metricOptions} placeholder="Select a Data field" @@ -181,25 +183,20 @@ export const Metric = (props: MetricCriteriaProps) => { newValue: { label: string; value: MetricAggregationType }, operation ) => { - if (operation === 'selectOption') { - field.onChange(newValue.value); - } - if (operation === 'clear') { - field.onChange(null); - } + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); }} value={ - field.value !== null - ? aggOptions.find( - (option) => option.value === field.value - ) - : null + aggOptions.find((option) => option.value === field.value) ?? + null } - data-testid="Aggregation-type" + data-testid="aggregation-type" disabled={aggOptions.length === 0} errorText={fieldState.error?.message} key={metricWatcher} label="Aggregation Type" + noMarginTop onBlur={field.onBlur} options={aggOptions} placeholder="Select an Aggregation type" @@ -210,7 +207,7 @@ export const Metric = (props: MetricCriteriaProps) => { name={`${name}.aggregation_type`} /> - + ( { selected: { label: string; value: MetricOperatorType }, operation ) => { - if (operation === 'selectOption') { - field.onChange(selected.value); - } - if (operation === 'clear') { - field.onChange(null); - } + field.onChange( + operation === 'selectOption' ? selected.value : null + ); }} value={ field.value !== null @@ -233,10 +227,11 @@ export const Metric = (props: MetricCriteriaProps) => { ) : null } - data-testid="Operator" + data-testid="operator" errorText={fieldState.error?.message} key={metricWatcher} label="Operator" + noMarginTop onBlur={field.onBlur} options={MetricOperatorOptions} placeholder="Select an operator" @@ -247,51 +242,53 @@ export const Metric = (props: MetricCriteriaProps) => { name={`${name}.operator`} /> - - - - ( - ) => - event.target instanceof HTMLElement && - event.target.blur() - } - data-testid="threshold" - errorText={fieldState.error?.message} - label="Threshold" - min={0} - name={`${name}.threshold`} - onBlur={field.onBlur} - onChange={(e) => field.onChange(e.target.value)} - sx={{ height: '34px' }} - type="number" - value={field.value ?? 0} - /> - )} - control={control} - name={`${name}.threshold`} - /> - - - - {/* There are discussions going on with the UX and within the team about the - * units being outside of the TextField or inside as an adornments - */} - {unit} - - - + + + ( + ) => + event.target instanceof HTMLElement && event.target.blur() + } + data-testid="threshold" + errorText={fieldState.error?.message} + label="Threshold" + min={0} + name={`${name}.threshold`} + noMarginTop + onBlur={field.onBlur} + onChange={(e) => field.onChange(e.target.value)} + sx={{ height: '34px', marginTop: { sm: 1, xs: 0 } }} + type="number" + value={field.value ?? 0} + /> + )} + control={control} + name={`${name}.threshold`} + /> + + {/* There are discussions going on with the UX and within the team about the + * units being outside of the TextField or inside as an adornments + */} + {unit} + + - + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx index 82a739fa29a..82654dcefb2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx @@ -243,43 +243,4 @@ describe('MetricCriteriaField', () => { expect(setMaxInterval).toBeCalledWith(firstOptionConvertedTime); }); - - it('setMaxInterval has to be called', async () => { - queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ - data: mockData, - isError: true, - isLoading: false, - status: 'error', - }); - const setMaxInterval = vi.fn(); - const firstOption = mockData.data[0]; - const secondOption = mockData.data[1]; - const [ - firstOptionConvertedTime, - secondOptionConvertedTime, - ] = convertToSeconds([ - firstOption.scrape_interval, - secondOption.scrape_interval, - ]); - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - rule_criteria: { - rules: [firstOption, secondOption], - }, - }, - }, - }); - - expect(setMaxInterval).toBeCalledWith( - Math.max(firstOptionConvertedTime, secondOptionConvertedTime) - ); - }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx new file mode 100644 index 00000000000..6c9f6ada950 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx @@ -0,0 +1,204 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { + EvaluationPeriodOptions, + PollingIntervalOptions, +} from '../../constants'; +import { TriggerConditions } from './TriggerConditions'; + +import type { CreateAlertDefinitionForm } from '../types'; + +const EvaluationPeriodTestId = 'evaluation-period'; + +const PollingIntervalTestId = 'polling-interval'; +describe('Trigger Conditions', () => { + const user = userEvent.setup(); + + it('should render all the components and names', () => { + const container = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + expect(container.getByLabelText('Evaluation Period')).toBeInTheDocument(); + expect(container.getByLabelText('Polling Interval')).toBeInTheDocument(); + expect( + container.getByText('Trigger alert when all criteria are met for') + ).toBeInTheDocument(); + expect( + container.getByText('consecutive occurrence(s).') + ).toBeInTheDocument(); + }); + + it('should render the tooltips for the Autocomplete components', () => { + const container = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + }); + + const evaluationPeriodContainer = container.getByTestId( + EvaluationPeriodTestId + ); + const evaluationPeriodToolTip = within(evaluationPeriodContainer).getByRole( + 'button', + { + name: + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + } + ); + const pollingIntervalContainer = container.getByTestId( + PollingIntervalTestId + ); + const pollingIntervalToolTip = within(pollingIntervalContainer).getByRole( + 'button', + { + name: 'Choose how often you intend to evaulate the alert condition.', + } + ); + expect(evaluationPeriodToolTip).toBeInTheDocument(); + expect(pollingIntervalToolTip).toBeInTheDocument(); + }); + + it('should render the Evaluation Period component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + } + ); + const evaluationPeriodContainer = container.getByTestId( + EvaluationPeriodTestId + ); + const evaluationPeriodInput = within( + evaluationPeriodContainer + ).getByRole('button', { name: 'Open' }); + + user.click(evaluationPeriodInput); + + expect( + await container.findByRole('option', { + name: EvaluationPeriodOptions.linode[1].label, + }) + ).toBeInTheDocument(); + expect( + await container.findByRole('option', { + name: EvaluationPeriodOptions.linode[2].label, + }) + ); + + await user.click( + container.getByRole('option', { + name: EvaluationPeriodOptions.linode[0].label, + }) + ); + + expect( + within(evaluationPeriodContainer).getByRole('combobox') + ).toHaveAttribute('value', EvaluationPeriodOptions.linode[0].label); + }); + + it('should render the Polling Interval component with options happy path and select an option', async () => { + const container = renderWithThemeAndHookFormContext( + { + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + } + ); + const pollingIntervalContainer = container.getByTestId( + PollingIntervalTestId + ); + const pollingIntervalInput = within( + pollingIntervalContainer + ).getByRole('button', { name: 'Open' }); + + user.click(pollingIntervalInput); + + expect( + await container.findByRole('option', { + name: PollingIntervalOptions.linode[1].label, + }) + ).toBeInTheDocument(); + + expect( + await container.findByRole('option', { + name: PollingIntervalOptions.linode[2].label, + }) + ); + + await user.click( + container.getByRole('option', { + name: PollingIntervalOptions.linode[0].label, + }) + ); + expect( + within(pollingIntervalContainer).getByRole('combobox') + ).toHaveAttribute('value', PollingIntervalOptions.linode[0].label); + }); + + it('should be able to show the options that are greater than or equal to max scraping Interval', () => { + const container = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + serviceType: 'linode', + }, + }, + }); + const evaluationPeriodContainer = container.getByTestId( + EvaluationPeriodTestId + ); + const evaluationPeriodInput = within( + evaluationPeriodContainer + ).getByRole('button', { name: 'Open' }); + + user.click(evaluationPeriodInput); + + expect( + screen.queryByText(EvaluationPeriodOptions.linode[0].label) + ).not.toBeInTheDocument(); + + const pollingIntervalContainer = container.getByTestId( + PollingIntervalTestId + ); + const pollingIntervalInput = within( + pollingIntervalContainer + ).getByRole('button', { name: 'Open' }); + user.click(pollingIntervalInput); + expect( + screen.queryByText(PollingIntervalOptions.linode[0].label) + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx new file mode 100644 index 00000000000..65338bf4548 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx @@ -0,0 +1,179 @@ +import { Autocomplete, Box, TextField, Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import * as React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { + EvaluationPeriodOptions, + PollingIntervalOptions, +} from '../../constants'; + +import type { CreateAlertDefinitionForm } from '../types'; +import type { + CreateAlertDefinitionPayload, + TriggerCondition, +} from '@linode/api-v4'; +import type { FieldPathByValue } from 'react-hook-form'; +interface TriggerConditionProps { + /** + * maximum scraping interval value for a metric to filter the evaluation period and polling interval options + */ + maxScrapingInterval: number; + /** + * name used for the component to set in form + */ + name: FieldPathByValue; +} +export const TriggerConditions = (props: TriggerConditionProps) => { + const { maxScrapingInterval, name } = props; + + const { control } = useFormContext(); + const serviceTypeWatcher = useWatch({ + control, + name: 'serviceType', + }); + const getPollingIntervalOptions = () => { + const options = serviceTypeWatcher + ? PollingIntervalOptions[serviceTypeWatcher] + : []; + return options.filter((item) => item.value >= maxScrapingInterval); + }; + + const getEvaluationPeriodOptions = () => { + const options = serviceTypeWatcher + ? EvaluationPeriodOptions[serviceTypeWatcher] + : []; + return options.filter((item) => item.value >= maxScrapingInterval); + }; + + return ( + ({ + backgroundColor: + theme.name === 'light' + ? theme.tokens.color.Neutrals[5] + : theme.tokens.color.Neutrals.Black, + borderRadius: 1, + marginTop: theme.spacing(2), + p: 2, + })} + > + Trigger Conditions + + + ( + { + field.onChange( + operation === 'selectOption' ? selected.value : null + ); + }} + textFieldProps={{ + labelTooltipText: + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + }} + value={ + getEvaluationPeriodOptions().find( + (option) => option.value === field.value + ) ?? null + } + data-testid="evaluation-period" + disabled={!serviceTypeWatcher} + errorText={fieldState.error?.message} + label="Evaluation Period" + onBlur={field.onBlur} + options={getEvaluationPeriodOptions()} + placeholder="Select an Evaluation period" + /> + )} + control={control} + name={`${name}.evaluation_period_seconds`} + /> + + + ( + { + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); + }} + textFieldProps={{ + labelTooltipText: + 'Choose how often you intend to evaulate the alert condition.', + }} + value={ + getPollingIntervalOptions().find( + (option) => option.value === field.value + ) ?? null + } + data-testid="polling-interval" + disabled={!serviceTypeWatcher} + errorText={fieldState.error?.message} + label="Polling Interval" + onBlur={field.onBlur} + options={getPollingIntervalOptions()} + placeholder="Select a Polling" + /> + )} + control={control} + name={`${name}.polling_interval_seconds`} + /> + + + + Trigger alert when all criteria are met for + + + ( + + event.target instanceof HTMLElement && event.target.blur() + } + sx={{ + height: '30px', + width: '30px', + }} + data-testid="trigger-occurences" + errorText={fieldState.error?.message} + label="" + min={0} + name={`${name}.trigger_occurrences`} + onBlur={field.onBlur} + onChange={(e) => field.onChange(e.target.value)} + type="number" + value={field.value ?? 0} + /> + )} + control={control} + name={`${name}.trigger_occurrences`} + /> + + + consecutive occurrence(s). + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx index 4d0dfd6bd4e..fce9818b017 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.test.tsx @@ -9,7 +9,7 @@ import { CloudPulseAlertSeveritySelect } from './AlertSeveritySelect'; describe('Severity component tests', () => { it('should render the component', () => { const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: , }); expect(getByLabelText('Severity')).toBeInTheDocument(); expect(getByTestId('severity')).toBeInTheDocument(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx index cdd10fe690a..376ed7ba7f3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertSeveritySelect.tsx @@ -47,7 +47,7 @@ export const CloudPulseAlertSeveritySelect = ( ) : null } - data-testid={'severity'} + data-testid="severity" errorText={fieldState.error?.message} label="Severity" onBlur={field.onBlur} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx index 1da3f1df9e0..f9b36f444ce 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EngineOption.test.tsx @@ -9,7 +9,7 @@ import { EngineOption } from './EngineOption'; describe('EngineOption component tests', () => { it('should render the component when resource type is dbaas', () => { const { getByLabelText, getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: , }); expect(getByLabelText('Engine Option')).toBeInTheDocument(); expect(getByTestId('engine-option')).toBeInTheDocument(); @@ -17,7 +17,7 @@ describe('EngineOption component tests', () => { it('should render the options happy path', async () => { const user = userEvent.setup(); renderWithThemeAndHookFormContext({ - component: , + component: , }); user.click(screen.getByRole('button', { name: 'Open' })); expect(await screen.findByRole('option', { name: 'MySQL' })); @@ -26,7 +26,7 @@ describe('EngineOption component tests', () => { it('should be able to select an option', async () => { const user = userEvent.setup(); renderWithThemeAndHookFormContext({ - component: , + component: , }); user.click(screen.getByRole('button', { name: 'Open' })); await user.click(await screen.findByRole('option', { name: 'MySQL' })); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 63959e9c0bb..90671fce719 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -3,13 +3,18 @@ import type { AlertSeverityType, CreateAlertDefinitionPayload, DimensionFilter, + DimensionFilterOperatorType, MetricAggregationType, MetricCriteria, MetricOperatorType, + TriggerCondition, } from '@linode/api-v4'; export interface CreateAlertDefinitionForm - extends Omit { + extends Omit< + CreateAlertDefinitionPayload, + 'rule_criteria' | 'severity' | 'trigger_conditions' + > { engineType: null | string; entity_ids: string[]; region: string; @@ -18,12 +23,32 @@ export interface CreateAlertDefinitionForm }; serviceType: AlertServiceType | null; severity: AlertSeverityType | null; + trigger_conditions: TriggerConditionForm; } export interface MetricCriteriaForm - extends Omit { + extends Omit< + MetricCriteria, + 'aggregation_type' | 'dimension_filters' | 'metric' | 'operator' + > { aggregation_type: MetricAggregationType | null; - dimension_filters: DimensionFilter[]; + dimension_filters: DimensionFilterForm[]; metric: null | string; operator: MetricOperatorType | null; } + +export interface DimensionFilterForm + extends Omit { + dimension_label: null | string; + operator: DimensionFilterOperatorType | null; + value: null | string; +} + +export interface TriggerConditionForm + extends Omit< + TriggerCondition, + 'evaluation_period_seconds' | 'polling_interval_seconds' + > { + evaluation_period_seconds: null | number; + polling_interval_seconds: null | number; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index 9a6bc907101..ba52a3b0068 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -1,9 +1,16 @@ import { omitProps } from '@linode/ui'; -import type { CreateAlertDefinitionForm, MetricCriteriaForm } from './types'; +import type { + CreateAlertDefinitionForm, + DimensionFilterForm, + MetricCriteriaForm, + TriggerConditionForm, +} from './types'; import type { CreateAlertDefinitionPayload, + DimensionFilter, MetricCriteria, + TriggerCondition, } from '@linode/api-v4'; // filtering out the form properties which are not part of the payload @@ -16,16 +23,18 @@ export const filterFormValues = ( 'engineType', 'severity', 'rule_criteria', + 'trigger_conditions', ]); - // severity has a need for null in the form for edge-cases, so null-checking and returning it as an appropriate type const severity = formValues.severity ?? 1; const entityIds = formValues.entity_ids; const rules = formValues.rule_criteria.rules; + const triggerConditions = formValues.trigger_conditions; return { ...values, entity_ids: entityIds, rule_criteria: { rules: filterMetricCriteriaFormValues(rules) }, severity, + trigger_conditions: filterTriggerConditionFormValues(triggerConditions), }; }; @@ -37,13 +46,37 @@ export const filterMetricCriteriaFormValues = ( return { ...values, aggregation_type: rule.aggregation_type ?? 'avg', - dimension_filters: rule.dimension_filters, + dimension_filters: filterDimensionFilterFormValues( + rule.dimension_filters + ), metric: rule.metric ?? '', operator: rule.operator ?? 'eq', }; }); }; +const filterDimensionFilterFormValues = ( + formValues: DimensionFilterForm[] +): DimensionFilter[] => { + return formValues.map((dimensionFilter) => { + return { + dimension_label: dimensionFilter.dimension_label ?? '', + operator: dimensionFilter.operator ?? 'eq', + value: dimensionFilter.value ?? '', + }; + }); +}; + +const filterTriggerConditionFormValues = ( + formValues: TriggerConditionForm +): TriggerCondition => { + return { + ...formValues, + evaluation_period_seconds: formValues.evaluation_period_seconds ?? 0, + polling_interval_seconds: formValues.polling_interval_seconds ?? 0, + }; +}; + export const convertToSeconds = (secondsList: string[]) => { return secondsList.map((second) => { const unit = second.slice(-1)[0]; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 53c6c880d91..f4fe1baefd3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,5 +1,6 @@ import type { AlertSeverityType, + DimensionFilterOperatorType, AlertStatusType, MetricAggregationType, MetricOperatorType, @@ -74,6 +75,48 @@ export const MetricAggregationOptions: Item[] = [ }, ]; +export const DimensionOperatorOptions: Item< + string, + DimensionFilterOperatorType +>[] = [ + { + label: 'Equal', + value: 'eq', + }, + { + label: 'Ends with', + value: 'endswith', + }, + { + label: 'Not Equal', + value: 'neq', + }, + { + label: 'Starts with', + value: 'startswith', + }, +]; + +export const EvaluationPeriodOptions = { + dbaas: [{ label: '5 min', value: 300 }], + linode: [ + { label: '1 min', value: 60 }, + { label: '5 min', value: 300 }, + { label: '15 min', value: 900 }, + { label: '30 min', value: 1800 }, + { label: '1 hr', value: 3600 }, + ], +}; + +export const PollingIntervalOptions = { + dbaas: [{ label: '5 min', value: 300 }], + linode: [ + { label: '1 min', value: 60 }, + { label: '5 min', value: 300 }, + { label: '10 min', value: 600 }, + ], +}; + export const severityMap: Record = { 0: 'Severe', 1: 'Medium', diff --git a/packages/manager/src/features/Events/FormattedEventMessage.tsx b/packages/manager/src/features/Events/FormattedEventMessage.tsx index f378c933be1..21c6c3d2d79 100644 --- a/packages/manager/src/features/Events/FormattedEventMessage.tsx +++ b/packages/manager/src/features/Events/FormattedEventMessage.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { SupportLink } from 'src/components/SupportLink'; interface MessageLinkEntity { + fallback?: string; message: null | string; } @@ -15,10 +16,10 @@ interface MessageLinkEntity { * - render "contact support" strings as . */ export const FormattedEventMessage = (props: MessageLinkEntity) => { - const { message } = props; + const { fallback, message } = props; if (!message) { - return null; + return fallback ? fallback : null; } return formatMessage(message); @@ -34,9 +35,14 @@ const formatMessage = (message: string): JSX.Element => { let formattedPart: JSX.Element | string = part; if (part.startsWith('`') && part.endsWith('`')) { - formattedPart = ( - {part.slice(1, -1)} - ); + const content = part.slice(1, -1); + if (content.length > 0) { + formattedPart = ( + {content} + ); + } else { + formattedPart = ''; + } } if (part.match(supportLinkMatch)) { diff --git a/packages/manager/src/features/Events/factories/domain.tsx b/packages/manager/src/features/Events/factories/domain.tsx index c29d2e95c27..61377e97427 100644 --- a/packages/manager/src/features/Events/factories/domain.tsx +++ b/packages/manager/src/features/Events/factories/domain.tsx @@ -30,12 +30,14 @@ export const domain: PartialEventMap<'domain'> = { ), }, domain_record_create: { - notification: (e) => ( - <> - has been{' '} - added to . - - ), + notification: (e) => { + return ( + <> + has + been added to . + + ); + }, }, domain_record_delete: { notification: (e) => ( diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 18a01362452..365a8758551 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -61,13 +61,14 @@ import type { KubeNodePoolResponse, KubernetesTier, } from '@linode/api-v4/lib/kubernetes'; +import type { Region } from '@linode/api-v4/lib/regions'; import type { APIError } from '@linode/api-v4/lib/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; export const CreateCluster = () => { const { classes } = useStyles(); - const [selectedRegionId, setSelectedRegionId] = React.useState< - string | undefined + const [selectedRegion, setSelectedRegion] = React.useState< + Region | undefined >(); const [nodePools, setNodePools] = React.useState([]); const [label, setLabel] = React.useState(); @@ -110,6 +111,11 @@ export const CreateCluster = () => { // HA is enabled by default for enterprise clusters if (tier === 'enterprise') { setHighAvailability(true); + + // When changing the tier to enterprise, we want to check if the pre-selected region has the capability + if (!selectedRegion?.capabilities.includes('Kubernetes Enterprise')) { + setSelectedRegion(undefined); + } } else { setHighAvailability(undefined); } @@ -214,7 +220,7 @@ export const CreateCluster = () => { k8s_version: version, label, node_pools, - region: selectedRegionId, + region: selectedRegion?.id, }; if (showAPL) { @@ -271,7 +277,7 @@ export const CreateCluster = () => { }; const highAvailabilityPrice = getDCSpecificPriceByType({ - regionId: selectedRegionId, + regionId: selectedRegion?.id, type: lkeHAType, }); @@ -295,7 +301,7 @@ export const CreateCluster = () => { isSelectedRegionEligibleForPlan, } = plansNoticesUtils({ regionsData, - selectedRegionID: selectedRegionId, + selectedRegionID: selectedRegion?.id, }); if (typesError || regionsError || versionsError) { @@ -362,9 +368,9 @@ export const CreateCluster = () => { } disableClearable errorText={errorMap.region} - onChange={(e, region) => setSelectedRegionId(region.id)} + onChange={(e, region) => setSelectedRegion(region)} regions={regionsData} - value={selectedRegionId} + value={selectedRegion?.id} /> { isAPLEnabled={apl_enabled} isErrorKubernetesTypes={isErrorKubernetesTypes} isLoadingKubernetesTypes={isLoadingKubernetesTypes} - selectedRegionId={selectedRegionId} + selectedRegionId={selectedRegion?.id} setHighAvailability={setHighAvailability} /> @@ -454,7 +460,7 @@ export const CreateCluster = () => { isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} regionsData={regionsData} - selectedRegionId={selectedRegionId} + selectedRegionId={selectedRegion?.id} types={typesData || []} typesLoading={typesLoading} /> @@ -473,7 +479,7 @@ export const CreateCluster = () => { updateFor={[ hasAgreed, highAvailability, - selectedRegionId, + selectedRegion?.id, nodePools, submitting, typesData, @@ -487,7 +493,7 @@ export const CreateCluster = () => { hasAgreed={hasAgreed} highAvailability={highAvailability} pools={nodePools} - region={selectedRegionId} + region={selectedRegion?.id} regionsData={regionsData} removePool={removePool} showHighAvailability={showHighAvailability} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx index a645ef6c422..7afeb9159e4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx @@ -14,13 +14,14 @@ 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 { CloneVolumeDrawer } from 'src/features/Volumes/CloneVolumeDrawer'; -import { DeleteVolumeDialog } from 'src/features/Volumes/DeleteVolumeDialog'; -import { DetachVolumeDialog } from 'src/features/Volumes/DetachVolumeDialog'; -import { EditVolumeDrawer } from 'src/features/Volumes/EditVolumeDrawer'; -import { ResizeVolumeDrawer } from 'src/features/Volumes/ResizeVolumeDrawer'; -import { VolumeDetailsDrawer } from 'src/features/Volumes/VolumeDetailsDrawer'; -import { LinodeVolumeAddDrawer } from 'src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer'; +import { DeleteVolumeDialog } from 'src/features/Volumes/Dialogs/DeleteVolumeDialog'; +import { DetachVolumeDialog } from 'src/features/Volumes/Dialogs/DetachVolumeDialog'; +import { CloneVolumeDrawer } from 'src/features/Volumes/Drawers/CloneVolumeDrawer'; +import { EditVolumeDrawer } from 'src/features/Volumes/Drawers/EditVolumeDrawer'; +import { ManageTagsDrawer } from 'src/features/Volumes/Drawers/ManageTagsDrawer'; +import { ResizeVolumeDrawer } from 'src/features/Volumes/Drawers/ResizeVolumeDrawer'; +import { VolumeDetailsDrawer } from 'src/features/Volumes/Drawers/VolumeDetailsDrawer'; +import { LinodeVolumeAddDrawer } from 'src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAddDrawer'; import { VolumeTableRow } from 'src/features/Volumes/VolumeTableRow'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useOrder } from 'src/hooks/useOrder'; @@ -75,6 +76,9 @@ export const LinodeVolumes = () => { isBlockStorageEncryptionFeatureEnabled, } = useIsBlockStorageEncryptionFeatureEnabled(); + const [isManageTagsDrawerOpen, setisManageTagsDrawerOpen] = React.useState( + false + ); const [selectedVolumeId, setSelectedVolumeId] = React.useState(); const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = React.useState(false); const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); @@ -106,6 +110,11 @@ export const LinodeVolumes = () => { setIsEditDrawerOpen(true); }; + const handleManageTags = (volume: Volume) => { + setSelectedVolumeId(volume.id); + setisManageTagsDrawerOpen(true); + }; + const handleResize = (volume: Volume) => { setSelectedVolumeId(volume.id); setIsResizeDrawerOpen(true); @@ -156,6 +165,7 @@ export const LinodeVolumes = () => { handleDetach: () => handleDetach(volume), handleDetails: () => handleDetails(volume), handleEdit: () => handleEdit(volume), + handleManageTags: () => handleManageTags(volume), handleResize: () => handleResize(volume), handleUpgrade: () => null, }} @@ -259,6 +269,11 @@ export const LinodeVolumes = () => { open={isEditDrawerOpen} volume={selectedVolume} /> + setisManageTagsDrawerOpen(false)} + open={isManageTagsDrawerOpen} + volume={selectedVolume} + /> setIsResizeDrawerOpen(false)} open={isResizeDrawerOpen} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx index 83204307066..589106b7c63 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx @@ -3,7 +3,7 @@ import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { VolumeUpgradeCopy } from 'src/features/Volumes/UpgradeVolumeDialog'; +import { VolumeUpgradeCopy } from 'src/features/Volumes/Dialogs/UpgradeVolumeDialog'; import { getUpgradeableVolumeIds } from 'src/features/Volumes/utils'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx index a27b86605b4..f2348c7390e 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -15,7 +16,9 @@ export const ConnectionRow = (props: Props) => { return ( {connection.name} - {connection.user} + + + {connection.count} ); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx index c43c27951a2..b0ab676c1ef 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -15,10 +16,14 @@ export const LongviewServiceRow = (props: Props) => { return ( {service.name} - {service.user} + + + {service.type} {service.port} - {service.ip} + + + ); }; 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 9792b9b9404..0daf59502a1 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; import OrderBy from 'src/components/OrderBy'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; @@ -186,7 +187,9 @@ export const ProcessesTableRow = React.memo((props: ProcessTableRowProps) => { {name} - {user} + + + {Math.round(maxCount)} diff --git a/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts b/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts index 266706a54e5..4827104cbd9 100644 --- a/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts +++ b/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts @@ -4,6 +4,12 @@ import Grid from '@mui/material/Unstable_Grid2'; export const StyledInstructionGrid = styled(Grid, { label: 'StyledInstructionGrid', })(({ theme }) => ({ + boxSizing: 'border-box', + columnGap: 1, + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + margin: '0', [theme.breakpoints.up('sm')]: { '&:not(:first-of-type)': { '&:before': { @@ -19,8 +25,6 @@ export const StyledInstructionGrid = styled(Grid, { width: 'auto', }, width: '100%', - boxSizing: 'border-box', - margin: '0', })); export const StyledContainerGrid = styled(Grid, { diff --git a/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx b/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx index 0d643784e03..316c1fb83f2 100644 --- a/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx +++ b/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx @@ -1,9 +1,11 @@ -import { Box, Typography } from '@linode/ui'; +import { Typography } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Link } from 'src/components/Link'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { StyledContainerGrid, @@ -17,6 +19,7 @@ interface Props { export const InstallationInstructions = React.memo((props: Props) => { const command = `curl -s https://lv.linode.com/${props.installationKey} | sudo bash`; + const theme = useTheme(); return ( @@ -41,9 +44,22 @@ export const InstallationInstructions = React.memo((props: Props) => { paddingTop: 0, }} > -
-              {command}
-            
+ +
+                {command}
+              
+
@@ -71,15 +87,16 @@ export const InstallationInstructions = React.memo((props: Props) => {
- - API Key:{' '} - ({ color: theme.color.grey1 })} - > - {props.APIKey} - + + API Key: + diff --git a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx index 2ae785f27ef..78ee76774e4 100644 --- a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx +++ b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx @@ -5,6 +5,7 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import ActionMenu from './ContactsActionMenu'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; import type { ManagedContact } from '@linode/api-v4/lib/managed'; @@ -19,14 +20,24 @@ export const ContactsRow = (props: ContactsRowProps) => { return ( - {contact.name} + + + - {contact.group} + + + - {contact.email} + + + - {contact.phone.primary} - {contact.phone.secondary} + + + + + + { const { data, error, isLoading } = useManagedSSHKey(); + const { data: preferences } = usePreferences(); const [copied, setCopied] = React.useState(false); + const [isSSHKeyMasked, setIsSSHKeyMasked] = React.useState( + preferences?.maskSensitiveData + ); const timeout = React.useRef(); + const matchesSmDownBreakpoint = useMediaQuery((theme: Theme) => + theme.breakpoints.down('sm') + ); React.useEffect(() => { if (copied) { @@ -71,27 +86,45 @@ const LinodePubKey = () => { - + Linode Public Key - + You must install our public SSH key on all managed Linodes so we can access them and diagnose issues. - {/* Hide the SSH key on x-small viewports */} - + - {data?.ssh_key || ''} + {preferences?.maskSensitiveData && isSSHKeyMasked ? ( + + ) : ( + data?.ssh_key || '' + )} {/* See NOTE A. If that CSS is removed, we can use the following instead: */} {/* pubKey.slice(0, 160)} . . . */} - + + + {preferences?.maskSensitiveData && ( + + )} + diff --git a/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx b/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx index 1bfc78f00f7..f4cbc97b773 100644 --- a/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx +++ b/packages/manager/src/features/Managed/SSHAccess/SSHAccessRow.tsx @@ -1,12 +1,14 @@ -import { ManagedLinodeSetting } from '@linode/api-v4/lib/managed'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import ActionMenu from './SSHAccessActionMenu'; +import type { ManagedLinodeSetting } from '@linode/api-v4/lib/managed'; + interface SSHAccessRowProps { linodeSetting: ManagedLinodeSetting; openDrawer: (linodeId: number) => void; @@ -28,9 +30,19 @@ export const SSHAccessRow = (props: SSHAccessRowProps) => { {isAccessEnabled ? 'Enabled' : 'Disabled'} - {linodeSetting.ssh.user} + + + - {linodeSetting.ssh.ip === 'any' ? 'Any' : linodeSetting.ssh.ip} + {linodeSetting.ssh.ip === 'any' ? ( + 'Any' + ) : ( + + )} {linodeSetting.ssh.port} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx index 77ad6bcf442..fa6e4576a1f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx @@ -15,8 +15,6 @@ const AUTHENTICATED_READ_TEXT = 'Authenticated Read'; const BUCKET_ACCESS_URL = '*object-storage/buckets/*/*/access'; const OBJECT_ACCESS_URL = '*object-storage/buckets/*/*/object-acl'; -vi.mock('src/components/EnhancedSelect/Select'); - const defaultProps: Props = { clusterOrRegion: 'in-maa', endpointType: 'E1', @@ -30,9 +28,6 @@ describe('AccessSelect', () => { flags: { objectStorageGen2: { enabled: true } }, }); - beforeEach(() => { - vi.clearAllMocks(); - }); it.each([ ['bucket', 'E0', true], ['bucket', 'E1', true], diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx index 5cd289372ed..e359f1425d5 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx @@ -23,8 +23,6 @@ vi.mock('@linode/api-v4/lib/object-storage/objects', async () => { }; }); -vi.mock('src/components/EnhancedSelect/Select'); - const props: ObjectDetailsDrawerProps = { bucketName: 'my-bucket', clusterId: 'cluster-id', diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx index 781eef74551..977d2ffd4e4 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx @@ -72,11 +72,11 @@ export const BucketTable = (props: Props) => { {isEndpointTypeAvailable && ( Endpoint Type diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.test.tsx index ba3a5831893..91a6aa04c49 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.test.tsx @@ -4,8 +4,6 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import ClusterSelect from './ClusterSelect'; -vi.mock('src/components/EnhancedSelect/Select'); - describe('ClusterSelect', () => { it('Renders a select with object storage clusters', () => { const { getByText } = renderWithTheme( diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx index 0e6ca834b39..f01236e1113 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx @@ -18,8 +18,6 @@ const props = { onClose: vi.fn(), }; -vi.mock('src/components/EnhancedSelect/Select'); - describe('CreateBucketDrawer', () => { it.skip('Should show a general error notice if the API returns one', async () => { server.use( diff --git a/packages/manager/src/features/ObjectStorage/utilities.ts b/packages/manager/src/features/ObjectStorage/utilities.ts index caed08f7d4e..b624606ef6f 100644 --- a/packages/manager/src/features/ObjectStorage/utilities.ts +++ b/packages/manager/src/features/ObjectStorage/utilities.ts @@ -1,13 +1,9 @@ import { OBJECT_STORAGE_DELIMITER } from 'src/constants'; import type { AccountSettings } from '@linode/api-v4/lib/account'; -import type { - ACLType, - ObjectStorageObject, -} from '@linode/api-v4/lib/object-storage'; +import type { ObjectStorageObject } from '@linode/api-v4/lib/object-storage'; import type { ObjectStorageEndpoint } from '@linode/api-v4/lib/object-storage'; import type { FormikProps } from 'formik'; -import type { Item } from 'src/components/EnhancedSelect/Select'; export const generateObjectUrl = (hostname: string, objectName: string) => { return `https://${hostname}/${objectName}`; @@ -43,6 +39,11 @@ export const basename = ( return path.substr(idx + 1); }; +export interface ACLType { + label: string; + value: string; +} + export interface ExtendedObject extends ObjectStorageObject { _displayName: string; _isFolder: boolean; @@ -148,18 +149,18 @@ export const confirmObjectStorage = async ( } }; -export const objectACLOptions: Item[] = [ +export const objectACLOptions: ACLType[] = [ { label: 'Private', value: 'private' }, { label: 'Authenticated Read', value: 'authenticated-read' }, { label: 'Public Read', value: 'public-read' }, ]; -export const bucketACLOptions: Item[] = [ +export const bucketACLOptions: ACLType[] = [ ...objectACLOptions, { label: 'Public Read/Write', value: 'public-read-write' }, ]; -export const objectACLHelperText: Record = { +export const objectACLHelperText: Record = { 'authenticated-read': 'Authenticated Read ACL', custom: 'Custom ACL', private: 'Private ACL', diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 462e03deb1b..aa78ef3798d 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -35,6 +35,7 @@ import { allScopesAreTheSame, basePermNameMap, hasAccessBeenSelectedForAllScopes, + levelMap, permTuplesToScopeString, scopeStringToPermTuples, } from './utils'; @@ -178,8 +179,8 @@ export const CreateAPITokenDrawer = (props: Props) => { // Permission scopes with a different default when Selecting All for the specified access level. const excludedScopesFromSelectAll: ExcludedScope[] = [ { - defaultAccessLevel: 0, - invalidAccessLevels: [1], + defaultAccessLevel: levelMap.none, + invalidAccessLevels: [levelMap.read_only], name: 'vpc', }, ]; @@ -366,7 +367,10 @@ export const CreateAPITokenDrawer = (props: Props) => { form.handleSubmit(), diff --git a/packages/manager/src/features/Profile/APITokens/utils.test.ts b/packages/manager/src/features/Profile/APITokens/utils.test.ts index 634baa93c5c..97e15da7928 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.test.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.test.ts @@ -436,6 +436,11 @@ describe('hasAccessBeenSelectedForAllScopes', () => { ['vpc', 0], ]; + const allExceptChildAccountSelectedScopes: Permission[] = [ + ...allSelectedScopes, + ['child_account', -1], + ]; + it('should return false if scopes are all set to a default of no selection', () => { expect(hasAccessBeenSelectedForAllScopes(defaultScopes)).toBe(false); }); @@ -447,4 +452,11 @@ describe('hasAccessBeenSelectedForAllScopes', () => { it('should return true if all scopes have a valid selection', () => { expect(hasAccessBeenSelectedForAllScopes(allSelectedScopes)).toBe(true); }); + it('should return true if all scopes except those excluded have a valid selection', () => { + expect( + hasAccessBeenSelectedForAllScopes(allExceptChildAccountSelectedScopes, [ + 'child_account', + ]) + ).toBe(true); + }); }); diff --git a/packages/manager/src/features/Profile/APITokens/utils.ts b/packages/manager/src/features/Profile/APITokens/utils.ts index 6ba792065e4..c8bbcb0d6f2 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.ts @@ -2,9 +2,7 @@ import { DateTime } from 'luxon'; import { isPast } from 'src/utilities/isPast'; -import { ExcludedScope } from './CreateAPITokenDrawer'; - -export type Permission = [keyof typeof basePermNameMap, number]; +import type { ExcludedScope } from './CreateAPITokenDrawer'; export const basePerms = [ 'account', @@ -46,6 +44,10 @@ export const basePermNameMap = { vpc: 'VPCs', } as const; +type PermissionKey = keyof typeof basePermNameMap; + +export type Permission = [PermissionKey, number]; + export const inverseLevelMap = ['none', 'read_only', 'read_write']; export const levelMap = { @@ -177,7 +179,7 @@ export const permTuplesToScopeString = (scopeTups: Permission[]): string => { } const joinedTups = scopeTups.reduce((acc, [key, value]) => { const level = inverseLevelMap[value]; - if (level !== 'none') { + if (level && level !== 'none') { return [...acc, [key, level].join(':')]; } return [...acc]; @@ -257,17 +259,21 @@ Omit => { * Determines whether a selection has been made for every scope, since by default, the scope permissions are set to null. * * @param scopeTuples - The array of scope tuples. + * @param excludedPerms - The permission keys to be excluded from this check. * @returns {boolean} True if all scopes have permissions set to none/read_only/read_write, false otherwise. */ export const hasAccessBeenSelectedForAllScopes = ( - scopeTuples: Permission[] + scopeTuples: Permission[], + excludedPerms?: PermissionKey[] ): boolean => { const validAccessLevels = [ levelMap['none'], levelMap['read_only'], levelMap['read_write'], ]; - return scopeTuples.every((scopeTuple) => - validAccessLevels.includes(scopeTuple[1]) + return scopeTuples.every( + (scopeTuple) => + validAccessLevels.includes(scopeTuple[1]) || + excludedPerms?.includes(scopeTuple[0]) ); }; diff --git a/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx similarity index 100% rename from packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx rename to packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx diff --git a/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx b/packages/manager/src/features/Volumes/Dialogs/DetachVolumeDialog.tsx similarity index 100% rename from packages/manager/src/features/Volumes/DetachVolumeDialog.tsx rename to packages/manager/src/features/Volumes/Dialogs/DetachVolumeDialog.tsx diff --git a/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx b/packages/manager/src/features/Volumes/Dialogs/UpgradeVolumeDialog.tsx similarity index 100% rename from packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx rename to packages/manager/src/features/Volumes/Dialogs/UpgradeVolumeDialog.tsx diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.test.tsx b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Volumes/AttachVolumeDrawer.test.tsx rename to packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.test.tsx diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx similarity index 100% rename from packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx rename to packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx diff --git a/packages/manager/src/features/Volumes/CloneVolumeDrawer.test.tsx b/packages/manager/src/features/Volumes/Drawers/CloneVolumeDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Volumes/CloneVolumeDrawer.test.tsx rename to packages/manager/src/features/Volumes/Drawers/CloneVolumeDrawer.test.tsx diff --git a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/CloneVolumeDrawer.tsx similarity index 99% rename from packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx rename to packages/manager/src/features/Volumes/Drawers/CloneVolumeDrawer.tsx index 229cb5feafe..097f52abea0 100644 --- a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/Drawers/CloneVolumeDrawer.tsx @@ -22,6 +22,7 @@ import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants import { PricePanel } from './VolumeDrawer/PricePanel'; import type { Volume } from '@linode/api-v4'; + interface Props { isFetching?: boolean; onClose: () => void; diff --git a/packages/manager/src/features/Volumes/EditVolumeDrawer.test.tsx b/packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Volumes/EditVolumeDrawer.test.tsx rename to packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.test.tsx diff --git a/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.tsx similarity index 84% rename from packages/manager/src/features/Volumes/EditVolumeDrawer.tsx rename to packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.tsx index 02ced6ec1ab..be2ab9d3313 100644 --- a/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/Drawers/EditVolumeDrawer.tsx @@ -7,7 +7,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { BLOCK_STORAGE_ENCRYPTION_SETTING_IMMUTABLE_COPY } from 'src/components/Encryption/constants'; import { useIsBlockStorageEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; -import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { useGrants } from 'src/queries/profile/profile'; import { useUpdateVolumeMutation } from 'src/queries/volumes/volumes'; import { @@ -48,9 +47,7 @@ export const EditVolumeDrawer = (props: Props) => { handleSubmit, isSubmitting, resetForm, - setFieldValue, status: error, - touched, values, } = useFormik({ enableReinitialize: true, @@ -58,7 +55,7 @@ export const EditVolumeDrawer = (props: Props) => { async onSubmit(values, { setErrors, setStatus }) { try { await updateVolume({ - label: values.label ?? '', + label: values.label, tags: values.tags, volumeId: volume?.id ?? -1, }); @@ -97,6 +94,7 @@ export const EditVolumeDrawer = (props: Props) => { /> )} {error && } + { required value={values.label} /> - - setFieldValue( - 'tags', - selected.map((item) => item.value) - ) - } - tagError={ - touched.tags - ? errors.tags - ? 'Unable to tag volume.' - : undefined - : undefined - } - disabled={isReadOnly} - label="Tags" - name="tags" - value={values.tags?.map((t) => ({ label: t, value: t })) ?? []} - /> + {isBlockStorageEncryptionFeatureEnabled && ( { /> )} + { + it('should render tags', async () => { + const volume = volumeFactory.build({ + tags: testTags, + }); + + server.use( + http.get(accountEndpoint, () => { + return HttpResponse.json(accountFactory.build()); + }) + ); + + const { getByText } = await renderWithThemeAndRouter( + + ); + + testTags.forEach((tag) => { + expect(getByText(tag)).toBeVisible(); + }); + }); + + it('should disable submit button when form is not dirty', async () => { + const volume = volumeFactory.build(); + + server.use( + http.get(accountEndpoint, () => { + return HttpResponse.json(accountFactory.build()); + }) + ); + + const { getByText } = await renderWithThemeAndRouter( + + ); + + expect(getByText('Save Changes').closest('button')).toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/Volumes/Drawers/ManageTagsDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/ManageTagsDrawer.tsx new file mode 100644 index 00000000000..0d823ff038a --- /dev/null +++ b/packages/manager/src/features/Volumes/Drawers/ManageTagsDrawer.tsx @@ -0,0 +1,121 @@ +import { Notice } from '@linode/ui'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; +import { useGrants } from 'src/queries/profile/profile'; +import { useUpdateVolumeMutation } from 'src/queries/volumes/volumes'; + +import type { APIError, Volume } from '@linode/api-v4'; + +interface Props { + isFetching?: boolean; + onClose: () => void; + open: boolean; + volume: Volume | undefined; +} + +export const ManageTagsDrawer = (props: Props) => { + const { isFetching, onClose: _onClose, open, volume } = props; + + const { data: grants } = useGrants(); + + const { mutateAsync: updateVolume } = useUpdateVolumeMutation(); + + const isReadOnly = + grants !== undefined && + grants.volume.find((grant) => grant.id === volume?.id)?.permissions === + 'read_only'; + + const { + control, + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + reset, + setError, + } = useForm<{ tags: string[] }>({ + values: { tags: volume?.tags ?? [] }, + }); + + const onSubmit = handleSubmit(async (values) => { + try { + await updateVolume({ + label: volume?.label ?? '', + tags: values.tags, + volumeId: volume?.id ?? -1, + }); + + onClose(); + } catch (errors) { + errors.forEach((error: APIError) => { + if (error.field == 'tags') { + setError('tags', { + message: error.reason, + }); + } else { + setError('root', { + message: + 'Unable to edit this Volume at this time. Please try again later.', + }); + } + }); + } + }); + + const onClose = () => { + _onClose(); + reset({ tags: volume?.tags }); + }; + + return ( + +
+ {isReadOnly && ( + + )} + {errors?.root && } + + ( + + field.onChange(selected.map((item) => item.value)) + } + disabled={isReadOnly} + label="Tags" + name="tags" + tagError={fieldState.error?.message} + value={field.value.map((t) => ({ label: t, value: t })) ?? []} + /> + )} + control={control} + name="tags" + /> + + + +
+ ); +}; diff --git a/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/ResizeVolumeDrawer.tsx similarity index 100% rename from packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx rename to packages/manager/src/features/Volumes/Drawers/ResizeVolumeDrawer.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDetailsDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDetailsDrawer.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDetailsDrawer.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDetailsDrawer.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/ConfigSelect.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/ConfigSelect.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/ConfigSelect.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/ConfigSelect.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAddDrawer.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAddDrawer.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAttachForm.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAttachForm.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeCreateForm.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeCreateForm.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeCreateForm.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeCreateForm.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/ModeSelection.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/ModeSelection.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/ModeSelection.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/ModeSelection.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/PricePanel.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/PricePanel.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/PricePanel.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/PricePanel.tsx diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/SizeField.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/SizeField.tsx similarity index 98% rename from packages/manager/src/features/Volumes/VolumeDrawer/SizeField.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/SizeField.tsx index 136b1cb32dc..85f22da957c 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/SizeField.tsx +++ b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/SizeField.tsx @@ -14,7 +14,7 @@ import { useVolumeTypesQuery } from 'src/queries/volumes/volumes'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; -import { SIZE_FIELD_WIDTH } from '../constants'; +import { SIZE_FIELD_WIDTH } from '../../constants'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/VolumeSelect.tsx b/packages/manager/src/features/Volumes/Drawers/VolumeDrawer/VolumeSelect.tsx similarity index 100% rename from packages/manager/src/features/Volumes/VolumeDrawer/VolumeSelect.tsx rename to packages/manager/src/features/Volumes/Drawers/VolumeDrawer/VolumeSelect.tsx diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index aba8857e316..ea21bb0f349 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -58,8 +58,8 @@ import { maybeCastToNumber } from 'src/utilities/maybeCastToNumber'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { SIZE_FIELD_WIDTH } from './constants'; -import { ConfigSelect } from './VolumeDrawer/ConfigSelect'; -import { SizeField } from './VolumeDrawer/SizeField'; +import { ConfigSelect } from './Drawers/VolumeDrawer/ConfigSelect'; +import { SizeField } from './Drawers/VolumeDrawer/SizeField'; import type { VolumeEncryption } from '@linode/api-v4'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx index e101f8a40b0..d704fcea738 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.test.tsx @@ -32,6 +32,7 @@ const handlers: ActionHandlers = { handleDetach: vi.fn(), handleDetails: vi.fn(), handleEdit: vi.fn(), + handleManageTags: vi.fn(), handleResize: vi.fn(), handleUpgrade: vi.fn(), }; diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx index 20a1ad44bc6..f33a114f621 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx @@ -18,6 +18,7 @@ const props: Props = { handleDetach: vi.fn(), handleDetails: vi.fn(), handleEdit: vi.fn(), + handleManageTags: vi.fn(), handleResize: vi.fn(), handleUpgrade: vi.fn(), }, diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index 8217745d659..b7898e8d8bf 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -1,14 +1,12 @@ -import { Volume } from '@linode/api-v4'; -import { Theme, useTheme } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import { splitAt } from 'ramda'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; -import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import type { Volume } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + export interface ActionHandlers { handleAttach: () => void; handleClone: () => void; @@ -16,6 +14,7 @@ export interface ActionHandlers { handleDetach: () => void; handleDetails: () => void; handleEdit: () => void; + handleManageTags: () => void; handleResize: () => void; handleUpgrade: () => void; } @@ -31,9 +30,6 @@ export const VolumesActionMenu = (props: Props) => { const attached = volume.linode_id !== null; - const theme = useTheme(); - const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); - const isVolumeReadOnly = useIsResourceRestricted({ grantLevel: 'read_only', grantType: 'volume', @@ -57,6 +53,11 @@ export const VolumesActionMenu = (props: Props) => { }) : undefined, }, + { + disabled: isVolumeReadOnly, + onClick: handlers.handleManageTags, + title: 'Manage Tags', + }, { disabled: isVolumeReadOnly, onClick: handlers.handleResize, @@ -126,27 +127,10 @@ export const VolumesActionMenu = (props: Props) => { : undefined, }); - const splitActionsArrayIndex = matchesSmDown ? 0 : 2; - const [inlineActions, menuActions] = splitAt(splitActionsArrayIndex, actions); - return ( - <> - {!matchesSmDown && - inlineActions.map((action) => { - return ( - - ); - })} - - + ); }; diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index aa2a4305d67..d4197689709 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -34,19 +34,23 @@ import { import { VOLUME_TABLE_PREFERENCE_KEY } from 'src/routes/volumes/constants'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { AttachVolumeDrawer } from './AttachVolumeDrawer'; -import { CloneVolumeDrawer } from './CloneVolumeDrawer'; -import { DeleteVolumeDialog } from './DeleteVolumeDialog'; -import { DetachVolumeDialog } from './DetachVolumeDialog'; -import { EditVolumeDrawer } from './EditVolumeDrawer'; -import { ResizeVolumeDrawer } from './ResizeVolumeDrawer'; -import { UpgradeVolumeDialog } from './UpgradeVolumeDialog'; -import { VolumeDetailsDrawer } from './VolumeDetailsDrawer'; +import { DeleteVolumeDialog } from './Dialogs/DeleteVolumeDialog'; +import { DetachVolumeDialog } from './Dialogs/DetachVolumeDialog'; +import { UpgradeVolumeDialog } from './Dialogs/UpgradeVolumeDialog'; +import { AttachVolumeDrawer } from './Drawers/AttachVolumeDrawer'; +import { CloneVolumeDrawer } from './Drawers/CloneVolumeDrawer'; +import { EditVolumeDrawer } from './Drawers/EditVolumeDrawer'; +import { ManageTagsDrawer } from './Drawers/ManageTagsDrawer'; +import { ResizeVolumeDrawer } from './Drawers/ResizeVolumeDrawer'; +import { VolumeDetailsDrawer } from './Drawers/VolumeDetailsDrawer'; import { VolumesLandingEmptyState } from './VolumesLandingEmptyState'; import { VolumeTableRow } from './VolumeTableRow'; import type { Filter, Volume } from '@linode/api-v4'; -import type { VolumesSearchParams } from 'src/routes/volumes/index'; +import type { + VolumeAction, + VolumesSearchParams, +} from 'src/routes/volumes/index'; export const VolumesLanding = () => { const navigate = useNavigate(); @@ -105,65 +109,9 @@ export const VolumesLanding = () => { redirectToOnNotFound: '/volumes', }); - const handleDetach = (volume: Volume) => { + const handleVolumeAction = (action: VolumeAction, volume: Volume) => { navigate({ - params: { action: 'detach', volumeId: volume.id }, - search: (prev) => prev, - to: `/volumes/$volumeId/$action`, - }); - }; - - const handleDelete = (volume: Volume) => { - navigate({ - params: { action: 'delete', volumeId: volume.id }, - search: (prev) => prev, - to: `/volumes/$volumeId/$action`, - }); - }; - - const handleDetails = (volume: Volume) => { - navigate({ - params: { action: 'details', volumeId: volume.id }, - search: (prev) => prev, - to: `/volumes/$volumeId/$action`, - }); - }; - - const handleEdit = (volume: Volume) => { - navigate({ - params: { action: 'edit', volumeId: volume.id }, - search: (prev) => prev, - to: `/volumes/$volumeId/$action`, - }); - }; - - const handleResize = (volume: Volume) => { - navigate({ - params: { action: 'resize', volumeId: volume.id }, - search: (prev) => prev, - to: `/volumes/$volumeId/$action`, - }); - }; - - const handleClone = (volume: Volume) => { - navigate({ - params: { action: 'clone', volumeId: volume.id }, - search: (prev) => prev, - to: `/volumes/$volumeId/$action`, - }); - }; - - const handleAttach = (volume: Volume) => { - navigate({ - params: { action: 'attach', volumeId: volume.id }, - search: (prev) => prev, - to: `/volumes/$volumeId/$action`, - }); - }; - - const handleUpgrade = (volume: Volume) => { - navigate({ - params: { action: 'upgrade', volumeId: volume.id }, + params: { action, volumeId: volume.id }, search: (prev) => prev, to: `/volumes/$volumeId/$action`, }); @@ -304,14 +252,16 @@ export const VolumesLanding = () => { {volumes?.data.map((volume) => ( handleAttach(volume), - handleClone: () => handleClone(volume), - handleDelete: () => handleDelete(volume), - handleDetach: () => handleDetach(volume), - handleDetails: () => handleDetails(volume), - handleEdit: () => handleEdit(volume), - handleResize: () => handleResize(volume), - handleUpgrade: () => handleUpgrade(volume), + handleAttach: () => handleVolumeAction('attach', volume), + handleClone: () => handleVolumeAction('clone', volume), + handleDelete: () => handleVolumeAction('delete', volume), + handleDetach: () => handleVolumeAction('detach', volume), + handleDetails: () => handleVolumeAction('details', volume), + handleEdit: () => handleVolumeAction('edit', volume), + handleManageTags: () => + handleVolumeAction('manage-tags', volume), + handleResize: () => handleVolumeAction('resize', volume), + handleUpgrade: () => handleVolumeAction('upgrade', volume), }} isBlockStorageEncryptionFeatureEnabled={ isBlockStorageEncryptionFeatureEnabled @@ -342,6 +292,12 @@ export const VolumesLanding = () => { open={params.action === 'details'} volume={selectedVolume} /> + [ + http.get( + '*/v4/domains/:id/records', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const domainRecords = domainRecordFactory.buildList(3); + + return makePaginatedResponse({ + data: domainRecords, + request, + }); + } + ), + + http.get( + '*/v4/domains', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const domains = await mswDB.getAll('domains'); + + if (!domains) { + return makeNotFoundResponse(); + } + + return makePaginatedResponse({ + data: domains, + request, + }); + } + ), + + http.get( + '*/v4/domains/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const domain = await mswDB.get('domains', id); + + if (!domain) { + return makeNotFoundResponse(); + } + + return makeResponse(domain); + } + ), +]; + +export const createDomain = (mockState: MockState) => [ + http.post( + '*/v4/domains', + async ({ request }): Promise> => { + const payload = await request.clone().json(); + + const domain = domainFactory.build({ + ...payload, + created: DateTime.now().toISO(), + last_updated: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }); + + await mswDB.add('domains', domain, mockState); + + queueEvents({ + event: { + action: 'domain_create', + entity: { + id: domain.id, + label: domain.domain, + type: 'domain', + url: `/v4/domains/${domain.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(domain); + } + ), +]; + +export const updateDomain = (mockState: MockState) => [ + http.put( + '*/v4/domains/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const domain = await mswDB.get('domains', id); + + if (!domain) { + return makeNotFoundResponse(); + } + + const payload = { + ...(await request.clone().json()), + last_updated: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }; + const updatedDomain = { ...domain, ...payload }; + + await mswDB.update('domains', id, updatedDomain, mockState); + + queueEvents({ + event: { + action: 'domain_update', + entity: { + id: domain.id, + label: domain.domain, + type: 'domain', + url: `/v4/domains/${domain.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedDomain); + } + ), +]; + +export const cloneDomain = (mockState: MockState) => [ + http.post( + '*/v4/domains/:id/clone', + async ({ params }): Promise> => { + const id = Number(params.id); + const domain = await mswDB.get('domains', id); + + if (!domain) { + return makeNotFoundResponse(); + } + + const clonedDomain = { + ...domain, + created: DateTime.now().toISO(), + last_updated: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }; + + await mswDB.add('domains', clonedDomain, mockState); + + queueEvents({ + event: { + action: 'domain_create', + entity: { + id: domain.id, + label: domain.domain, + type: 'domain', + url: `/v4/domains/${domain.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(domain); + } + ), +]; + +export const importDomain = (mockState: MockState) => [ + http.post( + '*/v4/domains/import', + async ({ request }): Promise> => { + const payload = await request.clone().json(); + + const domain = domainFactory.build({ + ...payload, + created: DateTime.now().toISO(), + last_updated: DateTime.now().toISO(), + updated: DateTime.now().toISO(), + }); + + await mswDB.add('domains', domain, mockState); + + queueEvents({ + event: { + action: 'domain_create', + entity: { + id: domain.id, + label: domain.domain, + type: 'domain', + url: `/v4/domains/${domain.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(domain); + } + ), +]; + +export const deleteDomains = (mockState: MockState) => [ + http.delete( + '*/v4/domains/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const domain = await mswDB.get('domains', id); + + if (!domain) { + return makeNotFoundResponse(); + } + + await mswDB.delete('domains', id, mockState); + + queueEvents({ + event: { + action: 'domain_delete', + entity: { + id: domain.id, + label: domain.domain, + type: 'domain', + url: `/v4/domains/${domain.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/seeds/domains.ts b/packages/manager/src/mocks/presets/crud/seeds/domains.ts new file mode 100644 index 00000000000..989aa240f83 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/domains.ts @@ -0,0 +1,32 @@ +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { domainFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; +import { seedWithUniqueIds } from 'src/mocks/presets/crud/seeds/utils'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const domainSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Domains Seeds', + group: { id: 'Domains' }, + id: 'domains:crud', + label: 'Domains', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[domainSeeder.id] ?? 0; + const domainSeeds = seedWithUniqueIds<'domains'>({ + dbEntities: await mswDB.getAll('domains'), + seedEntities: domainFactory.buildList(count), + }); + + const updatedMockState = { + ...mockState, + domains: mockState.domains.concat(domainSeeds), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts index f00084e08bc..3aab667a0e3 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/index.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts @@ -1,9 +1,11 @@ +import { domainSeeder } from './domains'; import { linodesSeeder } from './linodes'; import { placementGroupSeeder } from './placementGroups'; import { supportTicketsSeeder } from './supportTickets'; import { volumesSeeder } from './volumes'; export const dbSeeders = [ + domainSeeder, linodesSeeder, placementGroupSeeder, supportTicketsSeeder, diff --git a/packages/manager/src/mocks/presets/crud/seeds/utils.ts b/packages/manager/src/mocks/presets/crud/seeds/utils.ts index 5b77c2d2781..8fe4f536c89 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/utils.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/utils.ts @@ -13,6 +13,9 @@ import type { MockSeeder, MockState } from 'src/mocks/types'; */ export const removeSeeds = async (seederId: MockSeeder['id']) => { switch (seederId) { + case 'domains:crud': + await mswDB.deleteAll('domains', mockState, 'seedState'); + break; case 'linodes:crud': await mswDB.deleteAll('linodes', mockState, 'seedState'); await mswDB.deleteAll('linodeConfigs', mockState, 'seedState'); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 51f5dc70dae..da3347b9894 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2499,12 +2499,12 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg'], dimensions: [ { - dim_label: 'cpu', + dimension_label: 'cpu', label: 'CPU name', values: null, }, { - dim_label: 'state', + dimension_label: 'state', label: 'State of CPU', values: [ 'user', @@ -2518,7 +2518,7 @@ export const handlers = [ ], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, @@ -2533,7 +2533,7 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [ { - dim_label: 'state', + dimension_label: 'state', label: 'State of memory', values: [ 'used', @@ -2545,7 +2545,7 @@ export const handlers = [ ], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, @@ -2560,17 +2560,17 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [ { - dim_label: 'device', + dimension_label: 'device', label: 'Device name', values: ['lo', 'eth0'], }, { - dim_label: 'direction', + dimension_label: 'direction', label: 'Direction of network transfer', values: ['transmit', 'receive'], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, @@ -2585,17 +2585,17 @@ export const handlers = [ available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [ { - dim_label: 'device', + dimension_label: 'device', label: 'Device name', values: ['loop0', 'sda', 'sdb'], }, { - dim_label: 'direction', + dimension_label: 'direction', label: 'Operation direction', values: ['read', 'write'], }, { - dim_label: 'LINODE_ID', + dimension_label: 'LINODE_ID', label: 'Linode ID', values: null, }, diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 5b01ed9834e..7597a52cd2f 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -1,5 +1,7 @@ import type { Config, + Domain, + DomainRecord, Event, Firewall, Linode, @@ -79,9 +81,15 @@ export interface MockPresetExtra extends MockPresetBase { * Mock Preset Crud */ export type MockPresetCrudGroup = { - id: 'Linodes' | 'Placement Groups' | 'Support Tickets' | 'Volumes'; + id: + | 'Domains' + | 'Linodes' + | 'Placement Groups' + | 'Support Tickets' + | 'Volumes'; }; export type MockPresetCrudId = + | 'domains:crud' | 'linodes:crud' | 'placement-groups:crud' | 'support-tickets:crud' @@ -98,6 +106,8 @@ export type MockHandler = (mockState: MockState) => HttpHandler[]; * Stateful data shared among mocks. */ export interface MockState { + domainRecords: DomainRecord[]; + domains: Domain[]; eventQueue: Event[]; firewalls: Firewall[]; linodeConfigs: [number, Config][]; diff --git a/packages/manager/src/mocks/utilities/events.ts b/packages/manager/src/mocks/utilities/events.ts index 5c8b24a4286..f5c7bf4973d 100644 --- a/packages/manager/src/mocks/utilities/events.ts +++ b/packages/manager/src/mocks/utilities/events.ts @@ -28,7 +28,7 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const queueEvents = (props: QueuedEvents): Promise => { const { event, mockState, sequence } = props; - const initialDelay = 7500; + const initialDelay = 2500; const progressDelay = 10_000; let accumulatedDelay = 0; let lastEventWasProgress = false; diff --git a/packages/manager/src/routes/volumes/index.ts b/packages/manager/src/routes/volumes/index.ts index 87db6e3ce86..5ea6103beff 100644 --- a/packages/manager/src/routes/volumes/index.ts +++ b/packages/manager/src/routes/volumes/index.ts @@ -12,6 +12,7 @@ const volumeAction = { detach: 'detach', details: 'details', edit: 'edit', + 'manage-tags': 'manage-tags', resize: 'resize', upgrade: 'upgrade', } as const; diff --git a/packages/validation/.changeset/pr-11445-changed-1734706687923.md b/packages/validation/.changeset/pr-11445-changed-1734706687923.md new file mode 100644 index 00000000000..d9e29241267 --- /dev/null +++ b/packages/validation/.changeset/pr-11445-changed-1734706687923.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Error messages for few attributes ([#11445](https://github.com/linode/manager/pull/11445)) diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index 0128c96b73b..206385d3a80 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -1,7 +1,7 @@ import { array, number, object, string } from 'yup'; const dimensionFilters = object({ - dimension_label: string().required('Label is required for the filter.'), + dimension_label: string().required('Data Field is required for the filter.'), operator: string().required('Operator is required.'), value: string().required('Value is required.'), }); @@ -24,7 +24,8 @@ const triggerConditionValidation = object({ ), trigger_occurrences: number() .required('Trigger Occurrences is required.') - .positive('Number of occurrences must be greater than zero.'), + .positive('Value must be greater than zero.') + .typeError('Trigger Occurrences is required.'), }); export const createAlertDefinitionSchema = object({ diff --git a/packages/validation/src/volumes.schema.ts b/packages/validation/src/volumes.schema.ts index 19e5960c9d6..0c782ff7cc2 100644 --- a/packages/validation/src/volumes.schema.ts +++ b/packages/validation/src/volumes.schema.ts @@ -45,7 +45,8 @@ export const ResizeVolumeSchema = (minSize: number = 10) => }); export const UpdateVolumeSchema = object({ - label: string().required(), + label: string(), + tags: array().of(string()), }); export const AttachVolumeSchema = object({ diff --git a/yarn.lock b/yarn.lock index 6cc152bb1ae..4b31c7a5a11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4063,15 +4063,10 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== - -cookie@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@0.6.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== copy-to-clipboard@^3.0.8: version "3.3.3" @@ -9438,7 +9433,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@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": 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== @@ -9456,6 +9451,15 @@ 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" @@ -9536,7 +9540,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@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm: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== @@ -9550,6 +9554,13 @@ 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" @@ -10500,7 +10511,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@^7.0.0: +"wrap-ansi-cjs@npm: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== @@ -10518,6 +10529,15 @@ 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"